@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.
@@ -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__ */ jsx(
47
- Button,
59
+ title && /* @__PURE__ */ jsxs(
60
+ "div",
48
61
  {
49
- type: "button",
50
- variant: "muted",
51
- onClick: toggle,
52
62
  className: cn(
53
- "w-full px-4 py-3 text-sm font-medium hover:bg-accent/50 rounded-lg",
54
- chevronPosition === "left" ? "justify-start gap-2" : "justify-between"
63
+ "flex items-center gap-2 px-2 py-2",
64
+ chevronPosition === "left" ? "flex-row" : "flex-row"
55
65
  ),
56
- "aria-expanded": expanded,
57
- "aria-controls": contentId,
58
- children: chevronPosition === "left" ? /* @__PURE__ */ jsxs(Fragment, { children: [
59
- chevronIcon,
60
- /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
61
- icon && /* @__PURE__ */ jsxs("span", { className: "relative shrink-0 text-muted-foreground", children: [
62
- icon,
63
- !expanded && errorCount > 0 && /* @__PURE__ */ jsx("span", { className: "absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive" })
64
- ] }),
65
- /* @__PURE__ */ jsx("span", { children: title }),
66
- fieldCountLabel,
67
- errorBadge
68
- ] })
69
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
70
- /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
71
- icon && /* @__PURE__ */ jsxs("span", { className: "relative shrink-0 text-muted-foreground", children: [
72
- icon,
73
- !expanded && errorCount > 0 && /* @__PURE__ */ jsx("span", { className: "absolute -right-0.5 -top-0.5 size-2 rounded-full bg-destructive" })
74
- ] }),
75
- /* @__PURE__ */ jsx("span", { children: title }),
76
- fieldCountLabel,
77
- errorBadge
78
- ] }),
79
- chevronIcon
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 type=\"button\"\n variant=\"muted\"\n onClick={toggle}\n className={cn(\n 'w-full px-4 py-3 text-sm font-medium hover:bg-accent/50 rounded-lg',\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 )}\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": ";AAmCM,SAiDQ,UAjDR,KASA,YATA;AAlCN,YAAY,WAAW;AACvB,SAAS,mBAAmB;AAC5B,SAAS,UAAU;AACnB,SAAS,YAAY;AACrB,SAAS,cAAc;AACvB,SAAS,wBAAwB;AAkB1B,MAAM,mBAAmB,MAAM;AAAA,EACpC,SAASA,kBAAiB,EAAE,SAAS,OAAO,UAAU,kBAAkB,MAAM,aAAa,GAAG,YAAY,kBAAkB,SAAS,MAAM,SAAS,GAAG,KAAK;AAC1J,UAAM,IAAI,KAAK;AACf,UAAM,EAAE,UAAU,QAAQ,aAAa,WAAW,IAAI,iBAAiB,UAAU,SAAS,eAAe;AACzG,UAAM,YAAY,qBAAqB,OAAO;AAE9C,UAAM,oBAAoB,KAAK,OAAO;AAAA,MACpC,QAAQ,MAAM,YAAY,IAAI;AAAA,IAChC,IAAI,CAAC,WAAW,CAAC;AAEjB,UAAM,cACJ;AAAA,MAAC;AAAA;AAAA,QACC,WAAW;AAAA,UACT;AAAA,UACA,YAAY;AAAA,QACd;AAAA;AAAA,IACF;AAGF,UAAM,kBAAkB,OAAO,eAAe,YAAY,aAAa,IACrE,qBAAC,UAAK,WAAU,6CAA4C;AAAA;AAAA,MACvD;AAAA,MAAW;AAAA,MAAE,eAAe,IAC3B,EAAE,gCAAgC,OAAO,IACzC,EAAE,8BAA8B,QAAQ;AAAA,OAC9C,IACE;AAEJ,UAAM,aAAa,aAAa,IAC9B,oBAAC,UAAK,WAAU,4GACb,yBAAe,IACZ,EAAE,gCAAgC,mBAAmB,EAAE,OAAO,WAAW,CAAC,IAC1E,EAAE,8BAA8B,oBAAoB,EAAE,OAAO,WAAW,CAAC,GAC/E,IACE;AAEJ,WACE;AAAA,MAAC;AAAA;AAAA,QACC,IAAI,6BAA6B,OAAO;AAAA,QACxC,WAAW;AAAA,UACT;AAAA,UACA,CAAC,cAAc;AAAA,UACf,aAAa,KAAK;AAAA,QACpB;AAAA,QACA,6BAA2B;AAAA,QAC3B,6BAA2B,aAAa,SAAS;AAAA,QACjD,eAAa,aAAa,SAAY;AAAA,QAErC;AAAA,mBACC;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAQ;AAAA,cACR,SAAS;AAAA,cACT,WAAW;AAAA,gBACT;AAAA,gBACA,oBAAoB,SAAS,wBAAwB;AAAA,cACvD;AAAA,cACA,iBAAe;AAAA,cACf,iBAAe;AAAA,cAEd,8BAAoB,SACnB,iCACG;AAAA;AAAA,gBACD,qBAAC,UAAK,WAAU,2BACb;AAAA,0BAAQ,qBAAC,UAAK,WAAU,2CAA2C;AAAA;AAAA,oBAAM,CAAC,YAAY,aAAa,KAAK,oBAAC,UAAK,WAAU,mEAAkE;AAAA,qBAAG;AAAA,kBAC9L,oBAAC,UAAM,iBAAM;AAAA,kBACZ;AAAA,kBACA;AAAA,mBACH;AAAA,iBACF,IAEA,iCACE;AAAA,qCAAC,UAAK,WAAU,2BACb;AAAA,0BAAQ,qBAAC,UAAK,WAAU,2CAA2C;AAAA;AAAA,oBAAM,CAAC,YAAY,aAAa,KAAK,oBAAC,UAAK,WAAU,mEAAkE;AAAA,qBAAG;AAAA,kBAC9L,oBAAC,UAAM,iBAAM;AAAA,kBACZ;AAAA,kBACA;AAAA,mBACH;AAAA,gBACC;AAAA,iBACH;AAAA;AAAA,UAEJ;AAAA,UAEF;AAAA,YAAC;AAAA;AAAA,cACC,IAAI;AAAA,cACJ,WAAW;AAAA,gBACT;AAAA,gBACA,WAAW,+BAA+B;AAAA,cAC5C;AAAA,cAEA,8BAAC,SAAI,WAAU,aACZ,UACH;AAAA;AAAA,UACF;AAAA;AAAA;AAAA,IACF;AAAA,EAEJ;AACF;",
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.2912.8d7b1fef24",
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.2912.8d7b1fef24",
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.2912.8d7b1fef24",
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",
@@ -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 { DndContext, closestCenter, PointerSensor, KeyboardSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'
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 { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled })
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} {...attributes} {...listeners}>
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(KeyboardSensor),
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
- <Button
74
- type="button"
75
- variant="muted"
76
- onClick={toggle}
87
+ <div
77
88
  className={cn(
78
- 'w-full px-4 py-3 text-sm font-medium hover:bg-accent/50 rounded-lg',
79
- chevronPosition === 'left' ? 'justify-start gap-2' : 'justify-between',
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
- {chevronPosition === 'left' ? (
85
- <>
86
- {chevronIcon}
87
- <span className="flex items-center gap-2">
88
- {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>}
89
- <span>{title}</span>
90
- {fieldCountLabel}
91
- {errorBadge}
92
- </span>
93
- </>
94
- ) : (
95
- <>
96
- <span className="flex items-center gap-2">
97
- {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>}
98
- <span>{title}</span>
99
- {fieldCountLabel}
100
- {errorBadge}
101
- </span>
102
- {chevronIcon}
103
- </>
104
- )}
105
- </Button>
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
+ }