@open-mercato/ui 0.5.1-develop.2800.bfe2178a4f → 0.5.1-develop.2851.2854b4507f

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.
Files changed (33) hide show
  1. package/dist/backend/DataTable.js +15 -0
  2. package/dist/backend/DataTable.js.map +2 -2
  3. package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -1
  4. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +1 -1
  5. package/dist/backend/crud/CollapsibleGroup.js +64 -50
  6. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  7. package/dist/backend/crud/CollapsibleZoneLayout.js +23 -3
  8. package/dist/backend/crud/CollapsibleZoneLayout.js.map +2 -2
  9. package/dist/backend/crud/useGroupCollapse.js +2 -2
  10. package/dist/backend/crud/useGroupCollapse.js.map +2 -2
  11. package/dist/backend/crud/usePersistedBooleanFlag.js +57 -16
  12. package/dist/backend/crud/usePersistedBooleanFlag.js.map +2 -2
  13. package/dist/backend/crud/useZoneCollapse.js +2 -2
  14. package/dist/backend/crud/useZoneCollapse.js.map +2 -2
  15. package/dist/backend/messages/SendObjectMessageDialog.js +34 -13
  16. package/dist/backend/messages/SendObjectMessageDialog.js.map +2 -2
  17. package/dist/backend/version-history/VersionHistoryAction.js +3 -3
  18. package/dist/backend/version-history/VersionHistoryAction.js.map +2 -2
  19. package/package.json +3 -3
  20. package/src/backend/DataTable.tsx +16 -0
  21. package/src/backend/__tests__/CollapsibleZoneLayout.test.tsx +60 -0
  22. package/src/backend/__tests__/DataTable.extensions.test.tsx +61 -8
  23. package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -1
  24. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +44 -0
  25. package/src/backend/crud/CollapsibleGroup.tsx +12 -2
  26. package/src/backend/crud/CollapsibleZoneLayout.tsx +29 -4
  27. package/src/backend/crud/__tests__/usePersistedBooleanFlag.test.ts +83 -7
  28. package/src/backend/crud/useGroupCollapse.ts +2 -2
  29. package/src/backend/crud/usePersistedBooleanFlag.ts +75 -21
  30. package/src/backend/crud/useZoneCollapse.ts +2 -2
  31. package/src/backend/messages/SendObjectMessageDialog.tsx +37 -7
  32. package/src/backend/messages/__tests__/SendObjectMessageDialog.test.tsx +21 -0
  33. package/src/backend/version-history/VersionHistoryAction.tsx +4 -4
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import { Send } from "lucide-react";
5
5
  import { useT } from "@open-mercato/shared/lib/i18n/context";
6
6
  import { Button } from "../../primitives/button.js";
7
+ import { IconButton } from "../../primitives/icon-button.js";
7
8
  import {
8
9
  MessageComposer
9
10
  } from "./MessageComposer.js";
@@ -13,11 +14,16 @@ function SendObjectMessageDialog({
13
14
  lockedType = "messages.defaultWithObjects",
14
15
  requiredActionConfig = null,
15
16
  disabled = false,
17
+ buttonVariant = "ghost",
18
+ buttonSize = "icon",
19
+ buttonClassName,
20
+ buttonLabel,
16
21
  viewHref: _viewHref = null,
17
22
  onSuccess
18
23
  }) {
19
24
  const t = useT();
20
25
  const [open, setOpen] = React.useState(false);
26
+ const label = buttonLabel ?? t("messages.compose", "Compose message");
21
27
  const openComposer = React.useCallback(() => {
22
28
  if (disabled) return;
23
29
  setOpen(true);
@@ -30,20 +36,35 @@ function SendObjectMessageDialog({
30
36
  sourceEntityId: object.sourceEntityId ?? null,
31
37
  previewData: object.previewData ?? null
32
38
  }), [object.entityId, object.entityModule, object.entityType, object.sourceEntityId, object.sourceEntityType, object.previewData]);
39
+ const trigger = buttonSize === "icon" && (buttonVariant === "outline" || buttonVariant === "ghost") ? /* @__PURE__ */ jsx(
40
+ IconButton,
41
+ {
42
+ type: "button",
43
+ size: "default",
44
+ variant: buttonVariant,
45
+ className: buttonClassName,
46
+ disabled,
47
+ onClick: openComposer,
48
+ "aria-label": label,
49
+ title: label,
50
+ children: /* @__PURE__ */ jsx(Send, { className: "size-4" })
51
+ }
52
+ ) : /* @__PURE__ */ jsx(
53
+ Button,
54
+ {
55
+ type: "button",
56
+ size: buttonSize,
57
+ variant: buttonVariant,
58
+ className: buttonClassName,
59
+ disabled,
60
+ onClick: openComposer,
61
+ "aria-label": label,
62
+ title: label,
63
+ children: /* @__PURE__ */ jsx(Send, { className: "size-4" })
64
+ }
65
+ );
33
66
  return /* @__PURE__ */ jsxs(Fragment, { children: [
34
- /* @__PURE__ */ jsx(
35
- Button,
36
- {
37
- type: "button",
38
- size: "icon",
39
- variant: "ghost",
40
- disabled,
41
- onClick: openComposer,
42
- "aria-label": t("messages.compose", "Compose message"),
43
- title: t("messages.compose", "Compose message"),
44
- children: /* @__PURE__ */ jsx(Send, { className: "h-4 w-4" })
45
- }
46
- ),
67
+ trigger,
47
68
  /* @__PURE__ */ jsx(
48
69
  MessageComposer,
49
70
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/messages/SendObjectMessageDialog.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Send } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport {\n MessageComposer,\n type MessageComposerContextObject,\n type MessageComposerProps,\n type MessageComposerRequiredActionConfig,\n} from './MessageComposer'\n\nexport type SendObjectMessageDialogProps = {\n object: MessageComposerContextObject\n defaultValues?: MessageComposerProps['defaultValues']\n lockedType?: string | null\n requiredActionConfig?: MessageComposerRequiredActionConfig | null\n disabled?: boolean\n viewHref?: string | null\n onSuccess?: MessageComposerProps['onSuccess']\n}\n\nexport function SendObjectMessageDialog({\n object,\n defaultValues,\n lockedType = 'messages.defaultWithObjects',\n requiredActionConfig = null,\n disabled = false,\n viewHref: _viewHref = null,\n onSuccess,\n}: SendObjectMessageDialogProps) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n\n const openComposer = React.useCallback(() => {\n if (disabled) return\n setOpen(true)\n }, [disabled])\n const contextObject = React.useMemo(() => ({\n entityModule: object.entityModule,\n entityType: object.entityType,\n entityId: object.entityId,\n sourceEntityType: object.sourceEntityType ?? null,\n sourceEntityId: object.sourceEntityId ?? null,\n previewData: object.previewData ?? null,\n }), [object.entityId, object.entityModule, object.entityType, object.sourceEntityId, object.sourceEntityType, object.previewData])\n\n return (\n <>\n <Button\n type=\"button\"\n size=\"icon\"\n variant=\"ghost\"\n disabled={disabled}\n onClick={openComposer}\n aria-label={t('messages.compose', 'Compose message')}\n title={t('messages.compose', 'Compose message')}\n >\n <Send className=\"h-4 w-4\" />\n </Button>\n <MessageComposer\n variant=\"compose\"\n open={open}\n onOpenChange={setOpen}\n lockedType={lockedType}\n contextObject={contextObject}\n requiredActionConfig={requiredActionConfig}\n defaultValues={defaultValues}\n onSuccess={onSuccess}\n />\n </>\n )\n}\n"],
5
- "mappings": ";AAiDI,mBAUI,KAVJ;AA/CJ,YAAY,WAAW;AACvB,SAAS,YAAY;AACrB,SAAS,YAAY;AACrB,SAAS,cAAc;AACvB;AAAA,EACE;AAAA,OAIK;AAYA,SAAS,wBAAwB;AAAA,EACtC;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb,uBAAuB;AAAA,EACvB,WAAW;AAAA,EACX,UAAU,YAAY;AAAA,EACtB;AACF,GAAiC;AAC/B,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAE5C,QAAM,eAAe,MAAM,YAAY,MAAM;AAC3C,QAAI,SAAU;AACd,YAAQ,IAAI;AAAA,EACd,GAAG,CAAC,QAAQ,CAAC;AACb,QAAM,gBAAgB,MAAM,QAAQ,OAAO;AAAA,IACzC,cAAc,OAAO;AAAA,IACrB,YAAY,OAAO;AAAA,IACnB,UAAU,OAAO;AAAA,IACjB,kBAAkB,OAAO,oBAAoB;AAAA,IAC7C,gBAAgB,OAAO,kBAAkB;AAAA,IACzC,aAAa,OAAO,eAAe;AAAA,EACrC,IAAI,CAAC,OAAO,UAAU,OAAO,cAAc,OAAO,YAAY,OAAO,gBAAgB,OAAO,kBAAkB,OAAO,WAAW,CAAC;AAEjI,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,MAAK;AAAA,QACL,SAAQ;AAAA,QACR;AAAA,QACA,SAAS;AAAA,QACT,cAAY,EAAE,oBAAoB,iBAAiB;AAAA,QACnD,OAAO,EAAE,oBAAoB,iBAAiB;AAAA,QAE9C,8BAAC,QAAK,WAAU,WAAU;AAAA;AAAA,IAC5B;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR;AAAA,QACA,cAAc;AAAA,QACd;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Send } from 'lucide-react'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport {\n MessageComposer,\n type MessageComposerContextObject,\n type MessageComposerProps,\n type MessageComposerRequiredActionConfig,\n} from './MessageComposer'\n\nexport type SendObjectMessageDialogProps = {\n object: MessageComposerContextObject\n defaultValues?: MessageComposerProps['defaultValues']\n lockedType?: string | null\n requiredActionConfig?: MessageComposerRequiredActionConfig | null\n disabled?: boolean\n buttonVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'muted' | 'link'\n buttonSize?: 'default' | 'sm' | 'lg' | 'icon'\n buttonClassName?: string\n buttonLabel?: string\n viewHref?: string | null\n onSuccess?: MessageComposerProps['onSuccess']\n}\n\nexport function SendObjectMessageDialog({\n object,\n defaultValues,\n lockedType = 'messages.defaultWithObjects',\n requiredActionConfig = null,\n disabled = false,\n buttonVariant = 'ghost',\n buttonSize = 'icon',\n buttonClassName,\n buttonLabel,\n viewHref: _viewHref = null,\n onSuccess,\n}: SendObjectMessageDialogProps) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const label = buttonLabel ?? t('messages.compose', 'Compose message')\n\n const openComposer = React.useCallback(() => {\n if (disabled) return\n setOpen(true)\n }, [disabled])\n const contextObject = React.useMemo(() => ({\n entityModule: object.entityModule,\n entityType: object.entityType,\n entityId: object.entityId,\n sourceEntityType: object.sourceEntityType ?? null,\n sourceEntityId: object.sourceEntityId ?? null,\n previewData: object.previewData ?? null,\n }), [object.entityId, object.entityModule, object.entityType, object.sourceEntityId, object.sourceEntityType, object.previewData])\n\n const trigger = buttonSize === 'icon' && (buttonVariant === 'outline' || buttonVariant === 'ghost')\n ? (\n <IconButton\n type=\"button\"\n size=\"default\"\n variant={buttonVariant}\n className={buttonClassName}\n disabled={disabled}\n onClick={openComposer}\n aria-label={label}\n title={label}\n >\n <Send className=\"size-4\" />\n </IconButton>\n )\n : (\n <Button\n type=\"button\"\n size={buttonSize}\n variant={buttonVariant}\n className={buttonClassName}\n disabled={disabled}\n onClick={openComposer}\n aria-label={label}\n title={label}\n >\n <Send className=\"size-4\" />\n </Button>\n )\n\n return (\n <>\n {trigger}\n <MessageComposer\n variant=\"compose\"\n open={open}\n onOpenChange={setOpen}\n lockedType={lockedType}\n contextObject={contextObject}\n requiredActionConfig={requiredActionConfig}\n defaultValues={defaultValues}\n onSuccess={onSuccess}\n />\n </>\n )\n}\n"],
5
+ "mappings": ";AAsEQ,SAmBJ,UAnBI,KAmBJ,YAnBI;AApER,YAAY,WAAW;AACvB,SAAS,YAAY;AACrB,SAAS,YAAY;AACrB,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B;AAAA,EACE;AAAA,OAIK;AAgBA,SAAS,wBAAwB;AAAA,EACtC;AAAA,EACA;AAAA,EACA,aAAa;AAAA,EACb,uBAAuB;AAAA,EACvB,WAAW;AAAA,EACX,gBAAgB;AAAA,EAChB,aAAa;AAAA,EACb;AAAA,EACA;AAAA,EACA,UAAU,YAAY;AAAA,EACtB;AACF,GAAiC;AAC/B,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,QAAQ,eAAe,EAAE,oBAAoB,iBAAiB;AAEpE,QAAM,eAAe,MAAM,YAAY,MAAM;AAC3C,QAAI,SAAU;AACd,YAAQ,IAAI;AAAA,EACd,GAAG,CAAC,QAAQ,CAAC;AACb,QAAM,gBAAgB,MAAM,QAAQ,OAAO;AAAA,IACzC,cAAc,OAAO;AAAA,IACrB,YAAY,OAAO;AAAA,IACnB,UAAU,OAAO;AAAA,IACjB,kBAAkB,OAAO,oBAAoB;AAAA,IAC7C,gBAAgB,OAAO,kBAAkB;AAAA,IACzC,aAAa,OAAO,eAAe;AAAA,EACrC,IAAI,CAAC,OAAO,UAAU,OAAO,cAAc,OAAO,YAAY,OAAO,gBAAgB,OAAO,kBAAkB,OAAO,WAAW,CAAC;AAEjI,QAAM,UAAU,eAAe,WAAW,kBAAkB,aAAa,kBAAkB,WAEvF;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,MAAK;AAAA,MACL,SAAS;AAAA,MACT,WAAW;AAAA,MACX;AAAA,MACA,SAAS;AAAA,MACT,cAAY;AAAA,MACZ,OAAO;AAAA,MAEP,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,EAC3B,IAGA;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,MAAM;AAAA,MACN,SAAS;AAAA,MACT,WAAW;AAAA,MACX;AAAA,MACA,SAAS;AAAA,MACT,cAAY;AAAA,MACZ,OAAO;AAAA,MAEP,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,EAC3B;AAGJ,SACE,iCACG;AAAA;AAAA,IACD;AAAA,MAAC;AAAA;AAAA,QACC,SAAQ;AAAA,QACR;AAAA,QACA,cAAc;AAAA,QACd;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;",
6
6
  "names": []
7
7
  }
@@ -2,7 +2,7 @@
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { Clock } from "lucide-react";
5
- import { Button } from "../../primitives/button.js";
5
+ import { IconButton } from "../../primitives/icon-button.js";
6
6
  import { cn } from "@open-mercato/shared/lib/utils";
7
7
  import { useVersionHistory } from "./useVersionHistory.js";
8
8
  import { VersionHistoryPanel } from "./VersionHistoryPanel.js";
@@ -32,11 +32,11 @@ function VersionHistoryAction({
32
32
  if (!enabled) return null;
33
33
  return /* @__PURE__ */ jsxs(Fragment, { children: [
34
34
  /* @__PURE__ */ jsx(
35
- Button,
35
+ IconButton,
36
36
  {
37
37
  type: "button",
38
38
  variant: "ghost",
39
- size: "icon",
39
+ size: "default",
40
40
  onClick: () => setOpen(true),
41
41
  "aria-label": t("audit_logs.version_history.title"),
42
42
  title: t("audit_logs.version_history.title"),
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/version-history/VersionHistoryAction.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Clock } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { VersionHistoryConfig } from './types'\nimport { useVersionHistory } from './useVersionHistory'\nimport { VersionHistoryPanel } from './VersionHistoryPanel'\n\nexport type VersionHistoryActionProps = {\n config: VersionHistoryConfig | null\n t: TranslateFn\n buttonClassName?: string\n iconClassName?: string\n canUndoRedo?: boolean\n autoCheckAcl?: boolean\n}\n\nexport function VersionHistoryAction({\n config,\n t,\n buttonClassName,\n iconClassName,\n canUndoRedo,\n autoCheckAcl,\n}: VersionHistoryActionProps) {\n const enabled = Boolean(\n config?.resourceKind\n && config?.resourceId\n && String(config.resourceId).trim().length > 0\n )\n const [open, setOpen] = React.useState(false)\n const stableConfig = React.useMemo<VersionHistoryConfig | null>(() => {\n if (!enabled || !config) return null\n return {\n resourceKind: config.resourceKind,\n resourceId: config.resourceId,\n resourceIdFallback: config.resourceIdFallback,\n organizationId: config.organizationId,\n includeRelated: config.includeRelated,\n }\n }, [enabled, config?.resourceKind, config?.resourceId, config?.resourceIdFallback, config?.organizationId, config?.includeRelated])\n const historyData = useVersionHistory(stableConfig, open)\n\n if (!enabled) return null\n\n return (\n <>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n onClick={() => setOpen(true)}\n aria-label={t('audit_logs.version_history.title')}\n title={t('audit_logs.version_history.title')}\n className={buttonClassName}\n >\n <Clock className={cn('size-4', iconClassName)} />\n </Button>\n <VersionHistoryPanel\n open={open}\n onOpenChange={setOpen}\n entries={historyData.entries}\n isLoading={historyData.isLoading}\n error={historyData.error}\n hasMore={historyData.hasMore}\n onLoadMore={historyData.loadMore}\n t={t}\n canUndoRedo={canUndoRedo}\n autoCheckAcl={autoCheckAcl}\n />\n </>\n )\n}\n"],
5
- "mappings": ";AAiDI,mBAUI,KAVJ;AA/CJ,YAAY,WAAW;AACvB,SAAS,aAAa;AACtB,SAAS,cAAc;AACvB,SAAS,UAAU;AAGnB,SAAS,yBAAyB;AAClC,SAAS,2BAA2B;AAW7B,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA8B;AAC5B,QAAM,UAAU;AAAA,IACd,QAAQ,gBACH,QAAQ,cACR,OAAO,OAAO,UAAU,EAAE,KAAK,EAAE,SAAS;AAAA,EACjD;AACA,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,eAAe,MAAM,QAAqC,MAAM;AACpE,QAAI,CAAC,WAAW,CAAC,OAAQ,QAAO;AAChC,WAAO;AAAA,MACL,cAAc,OAAO;AAAA,MACrB,YAAY,OAAO;AAAA,MACnB,oBAAoB,OAAO;AAAA,MAC3B,gBAAgB,OAAO;AAAA,MACvB,gBAAgB,OAAO;AAAA,IACzB;AAAA,EACF,GAAG,CAAC,SAAS,QAAQ,cAAc,QAAQ,YAAY,QAAQ,oBAAoB,QAAQ,gBAAgB,QAAQ,cAAc,CAAC;AAClI,QAAM,cAAc,kBAAkB,cAAc,IAAI;AAExD,MAAI,CAAC,QAAS,QAAO;AAErB,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS,MAAM,QAAQ,IAAI;AAAA,QAC3B,cAAY,EAAE,kCAAkC;AAAA,QAChD,OAAO,EAAE,kCAAkC;AAAA,QAC3C,WAAW;AAAA,QAEX,8BAAC,SAAM,WAAW,GAAG,UAAU,aAAa,GAAG;AAAA;AAAA,IACjD;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,cAAc;AAAA,QACd,SAAS,YAAY;AAAA,QACrB,WAAW,YAAY;AAAA,QACvB,OAAO,YAAY;AAAA,QACnB,SAAS,YAAY;AAAA,QACrB,YAAY,YAAY;AAAA,QACxB;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { Clock } from 'lucide-react'\nimport { IconButton } from '../../primitives/icon-button'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { VersionHistoryConfig } from './types'\nimport { useVersionHistory } from './useVersionHistory'\nimport { VersionHistoryPanel } from './VersionHistoryPanel'\n\nexport type VersionHistoryActionProps = {\n config: VersionHistoryConfig | null\n t: TranslateFn\n buttonClassName?: string\n iconClassName?: string\n canUndoRedo?: boolean\n autoCheckAcl?: boolean\n}\n\nexport function VersionHistoryAction({\n config,\n t,\n buttonClassName,\n iconClassName,\n canUndoRedo,\n autoCheckAcl,\n}: VersionHistoryActionProps) {\n const enabled = Boolean(\n config?.resourceKind\n && config?.resourceId\n && String(config.resourceId).trim().length > 0\n )\n const [open, setOpen] = React.useState(false)\n const stableConfig = React.useMemo<VersionHistoryConfig | null>(() => {\n if (!enabled || !config) return null\n return {\n resourceKind: config.resourceKind,\n resourceId: config.resourceId,\n resourceIdFallback: config.resourceIdFallback,\n organizationId: config.organizationId,\n includeRelated: config.includeRelated,\n }\n }, [enabled, config?.resourceKind, config?.resourceId, config?.resourceIdFallback, config?.organizationId, config?.includeRelated])\n const historyData = useVersionHistory(stableConfig, open)\n\n if (!enabled) return null\n\n return (\n <>\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"default\"\n onClick={() => setOpen(true)}\n aria-label={t('audit_logs.version_history.title')}\n title={t('audit_logs.version_history.title')}\n className={buttonClassName}\n >\n <Clock className={cn('size-4', iconClassName)} />\n </IconButton>\n <VersionHistoryPanel\n open={open}\n onOpenChange={setOpen}\n entries={historyData.entries}\n isLoading={historyData.isLoading}\n error={historyData.error}\n hasMore={historyData.hasMore}\n onLoadMore={historyData.loadMore}\n t={t}\n canUndoRedo={canUndoRedo}\n autoCheckAcl={autoCheckAcl}\n />\n </>\n )\n}\n"],
5
+ "mappings": ";AAiDI,mBAUI,KAVJ;AA/CJ,YAAY,WAAW;AACvB,SAAS,aAAa;AACtB,SAAS,kBAAkB;AAC3B,SAAS,UAAU;AAGnB,SAAS,yBAAyB;AAClC,SAAS,2BAA2B;AAW7B,SAAS,qBAAqB;AAAA,EACnC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA8B;AAC5B,QAAM,UAAU;AAAA,IACd,QAAQ,gBACH,QAAQ,cACR,OAAO,OAAO,UAAU,EAAE,KAAK,EAAE,SAAS;AAAA,EACjD;AACA,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,eAAe,MAAM,QAAqC,MAAM;AACpE,QAAI,CAAC,WAAW,CAAC,OAAQ,QAAO;AAChC,WAAO;AAAA,MACL,cAAc,OAAO;AAAA,MACrB,YAAY,OAAO;AAAA,MACnB,oBAAoB,OAAO;AAAA,MAC3B,gBAAgB,OAAO;AAAA,MACvB,gBAAgB,OAAO;AAAA,IACzB;AAAA,EACF,GAAG,CAAC,SAAS,QAAQ,cAAc,QAAQ,YAAY,QAAQ,oBAAoB,QAAQ,gBAAgB,QAAQ,cAAc,CAAC;AAClI,QAAM,cAAc,kBAAkB,cAAc,IAAI;AAExD,MAAI,CAAC,QAAS,QAAO;AAErB,SACE,iCACE;AAAA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,SAAS,MAAM,QAAQ,IAAI;AAAA,QAC3B,cAAY,EAAE,kCAAkC;AAAA,QAChD,OAAO,EAAE,kCAAkC;AAAA,QAC3C,WAAW;AAAA,QAEX,8BAAC,SAAM,WAAW,GAAG,UAAU,aAAa,GAAG;AAAA;AAAA,IACjD;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,cAAc;AAAA,QACd,SAAS,YAAY;AAAA,QACrB,WAAW,YAAY;AAAA,QACvB,OAAO,YAAY;AAAA,QACnB,SAAS,YAAY;AAAA,QACrB,YAAY,YAAY;AAAA,QACxB;AAAA,QACA;AAAA,QACA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.5.1-develop.2800.bfe2178a4f",
3
+ "version": "0.5.1-develop.2851.2854b4507f",
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.2800.bfe2178a4f",
135
+ "@open-mercato/shared": "0.5.1-develop.2851.2854b4507f",
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.2800.bfe2178a4f",
140
+ "@open-mercato/shared": "0.5.1-develop.2851.2854b4507f",
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",
@@ -204,6 +204,7 @@ export type DataTableProps<T> = {
204
204
  rowClickActionIds?: string[]
205
205
  disableRowClick?: boolean
206
206
  bulkActions?: BulkAction<T>[]
207
+ selectionScopeKey?: string
207
208
 
208
209
  // Auto FilterBar options (rendered as toolbar when provided and no custom toolbar passed)
209
210
  searchValue?: string
@@ -861,6 +862,7 @@ export function DataTable<T>({
861
862
  rowClickActionIds,
862
863
  disableRowClick = false,
863
864
  bulkActions: bulkActionsProp,
865
+ selectionScopeKey,
864
866
  searchValue,
865
867
  onSearchChange,
866
868
  searchPlaceholder,
@@ -1289,6 +1291,7 @@ export function DataTable<T>({
1289
1291
  const hasPropBulkActions = Array.isArray(bulkActionsProp) && bulkActionsProp.length > 0
1290
1292
  const hasInjectedBulkActions = injectedBulkActions.length > 0 || hasPropBulkActions
1291
1293
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
1294
+ const selectionScopeKeyRef = React.useRef<string | undefined>(selectionScopeKey)
1292
1295
  const table = useReactTable<T>({
1293
1296
  data: clientFilteredData,
1294
1297
  columns: mergedColumns,
@@ -1313,6 +1316,19 @@ export function DataTable<T>({
1313
1316
  onRowSelectionChange: setRowSelection,
1314
1317
  })
1315
1318
  React.useEffect(() => { if (sortingProp) setSorting(sortingProp) }, [sortingProp])
1319
+ React.useEffect(() => {
1320
+ if (selectionScopeKey === undefined) {
1321
+ selectionScopeKeyRef.current = undefined
1322
+ return
1323
+ }
1324
+ if (selectionScopeKeyRef.current === undefined) {
1325
+ selectionScopeKeyRef.current = selectionScopeKey
1326
+ return
1327
+ }
1328
+ if (selectionScopeKeyRef.current === selectionScopeKey) return
1329
+ selectionScopeKeyRef.current = selectionScopeKey
1330
+ setRowSelection({})
1331
+ }, [selectionScopeKey])
1316
1332
  React.useEffect(() => {
1317
1333
  if (hasInjectedBulkActions) return
1318
1334
  if (Object.keys(rowSelection).length === 0) return
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import { act, fireEvent, screen, waitFor } from '@testing-library/react'
5
+ import { User } from 'lucide-react'
5
6
  import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
6
7
  import { CollapsibleZoneLayout } from '../crud/CollapsibleZoneLayout'
7
8
 
@@ -76,6 +77,19 @@ describe('CollapsibleZoneLayout', () => {
76
77
  writable: true,
77
78
  value: currentWidth,
78
79
  })
80
+ const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
81
+ return window.setTimeout(() => callback(0), 0)
82
+ }
83
+ Object.defineProperty(window, 'requestAnimationFrame', {
84
+ configurable: true,
85
+ writable: true,
86
+ value: requestAnimationFrameMock,
87
+ })
88
+ Object.defineProperty(globalThis, 'requestAnimationFrame', {
89
+ configurable: true,
90
+ writable: true,
91
+ value: requestAnimationFrameMock,
92
+ })
79
93
 
80
94
  Object.defineProperty(window, 'matchMedia', {
81
95
  configurable: true,
@@ -168,4 +182,50 @@ describe('CollapsibleZoneLayout', () => {
168
182
  expect(zone1.compareDocumentPosition(zone2) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
169
183
  expect(screen.getByRole('button', { name: 'Collapse form panel' })).toBeInTheDocument()
170
184
  })
185
+
186
+ it('expands and navigates to a section from the collapsed rail', async () => {
187
+ currentWidth = 1180
188
+ Object.defineProperty(window, 'innerWidth', {
189
+ configurable: true,
190
+ writable: true,
191
+ value: currentWidth,
192
+ })
193
+ const scrollIntoView = jest.fn()
194
+ Object.defineProperty(Element.prototype, 'scrollIntoView', {
195
+ configurable: true,
196
+ writable: true,
197
+ value: scrollIntoView,
198
+ })
199
+
200
+ const { container } = renderWithProviders(
201
+ <CollapsibleZoneLayout
202
+ zone1={(
203
+ <div id="collapsible-group-wrapper-personalData">
204
+ <button type="button" aria-controls="collapsible-group-personalData">Personal group</button>
205
+ </div>
206
+ )}
207
+ zone2={<div>Zone 2</div>}
208
+ entityName="Ada Lovelace"
209
+ pageType="person-v2"
210
+ sections={[
211
+ { id: 'personalData', icon: User, label: 'Personal data' },
212
+ ]}
213
+ />,
214
+ { dict: {} },
215
+ )
216
+
217
+ const layout = container.firstElementChild as HTMLElement
218
+
219
+ await waitFor(() => {
220
+ expect(layout).toHaveAttribute('data-zone-layout-mode', 'collapsed')
221
+ })
222
+
223
+ fireEvent.click(screen.getByRole('button', { name: 'Personal data' }))
224
+
225
+ await waitFor(() => {
226
+ expect(layout).toHaveAttribute('data-zone-layout-mode', 'stacked')
227
+ expect(screen.getByText('Personal group')).toHaveFocus()
228
+ })
229
+ expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' })
230
+ })
171
231
  })
@@ -41,19 +41,21 @@ class ResizeObserverMock {
41
41
 
42
42
  function renderTable(elementProps: Record<string, unknown>) {
43
43
  const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
44
- const view = render(
44
+ const renderElement = (props: Record<string, unknown>) => React.createElement(
45
+ QueryClientProvider as any,
46
+ { client: queryClient },
45
47
  React.createElement(
46
- QueryClientProvider as any,
47
- { client: queryClient },
48
- React.createElement(
49
- I18nProvider as any,
50
- { locale: 'en', dict: {} },
51
- React.createElement(DataTable as any, elementProps),
52
- ),
48
+ I18nProvider as any,
49
+ { locale: 'en', dict: {} },
50
+ React.createElement(DataTable as any, props),
53
51
  ),
54
52
  )
53
+ const view = render(
54
+ renderElement(elementProps),
55
+ )
55
56
  return {
56
57
  ...view,
58
+ rerenderTable: (nextProps: Record<string, unknown>) => view.rerender(renderElement(nextProps)),
57
59
  cleanupQueryClient: () => queryClient.clear(),
58
60
  }
59
61
  }
@@ -315,6 +317,57 @@ describe('DataTable extensions', () => {
315
317
  }
316
318
  })
317
319
 
320
+ it('clears selection when selectionScopeKey changes', async () => {
321
+ const rendered = renderTable({
322
+ columns: [{ accessorKey: 'name', header: 'Name' }],
323
+ data: [{ id: 'r1', name: 'Alice' }],
324
+ bulkActions: [{ id: 'bulk', label: 'Bulk', onExecute: jest.fn() }],
325
+ selectionScopeKey: 'scope-1',
326
+ })
327
+
328
+ try {
329
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
330
+
331
+ await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
332
+
333
+ rendered.rerenderTable({
334
+ columns: [{ accessorKey: 'name', header: 'Name' }],
335
+ data: [{ id: 'r1', name: 'Alice' }],
336
+ bulkActions: [{ id: 'bulk', label: 'Bulk', onExecute: jest.fn() }],
337
+ selectionScopeKey: 'scope-2',
338
+ })
339
+
340
+ await waitFor(() => expect(screen.queryByText('1 selected')).not.toBeInTheDocument())
341
+ } finally {
342
+ rendered.cleanupQueryClient()
343
+ }
344
+ })
345
+
346
+ it('keeps selection unchanged when selectionScopeKey is omitted', async () => {
347
+ const rendered = renderTable({
348
+ columns: [{ accessorKey: 'name', header: 'Name' }],
349
+ data: [{ id: 'r1', name: 'Alice' }],
350
+ bulkActions: [{ id: 'bulk', label: 'Bulk', onExecute: jest.fn() }],
351
+ })
352
+
353
+ try {
354
+ fireEvent.click(screen.getByRole('checkbox', { name: 'Select all rows' }))
355
+
356
+ await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
357
+
358
+ rendered.rerenderTable({
359
+ columns: [{ accessorKey: 'name', header: 'Name' }],
360
+ data: [{ id: 'r1', name: 'Alice' }],
361
+ bulkActions: [{ id: 'bulk', label: 'Bulk', onExecute: jest.fn() }],
362
+ title: 'Messages',
363
+ })
364
+
365
+ await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument())
366
+ } finally {
367
+ rendered.cleanupQueryClient()
368
+ }
369
+ })
370
+
318
371
  it('applies sticky positioning to the actions column when enabled', () => {
319
372
  const rendered = renderTable({
320
373
  columns: [{ accessorKey: 'name', header: 'Name' }],
@@ -188,7 +188,7 @@ export function ConfirmDialog({
188
188
  onClick={handleBackdropClick}
189
189
  className={cn(
190
190
  // Reset dialog defaults
191
- "m-0 p-0 max-w-none bg-transparent border-none",
191
+ "m-0 p-0 max-w-none bg-transparent border-none pointer-events-auto",
192
192
  // Backdrop styling
193
193
  "backdrop:bg-black/50 backdrop:backdrop-blur-sm backdrop:transition-opacity",
194
194
  // Mobile: bottom sheet
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import * as React from 'react'
5
+ import { screen } from '@testing-library/react'
6
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
7
+ import { ConfirmDialog } from '../ConfirmDialog'
8
+
9
+ function installDialogPolyfill() {
10
+ Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
11
+ configurable: true,
12
+ value(this: HTMLDialogElement) {
13
+ this.setAttribute('open', '')
14
+ },
15
+ })
16
+ Object.defineProperty(HTMLDialogElement.prototype, 'close', {
17
+ configurable: true,
18
+ value(this: HTMLDialogElement) {
19
+ this.removeAttribute('open')
20
+ },
21
+ })
22
+ }
23
+
24
+ describe('ConfirmDialog', () => {
25
+ beforeEach(() => {
26
+ installDialogPolyfill()
27
+ })
28
+
29
+ it('restores pointer events for nested modal confirmations', () => {
30
+ renderWithProviders(
31
+ <ConfirmDialog
32
+ open
33
+ onOpenChange={() => undefined}
34
+ onConfirm={() => undefined}
35
+ title="Discard unsaved changes?"
36
+ text="Changes will be discarded."
37
+ confirmText="Discard"
38
+ cancelText="Cancel"
39
+ />,
40
+ )
41
+
42
+ expect(screen.getByRole('alertdialog').className).toEqual(expect.stringContaining('pointer-events-auto'))
43
+ })
44
+ })
@@ -25,7 +25,7 @@ export interface CollapsibleGroupHandle {
25
25
  export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, CollapsibleGroupProps>(
26
26
  function CollapsibleGroup({ groupId, title, pageType, defaultExpanded = true, errorCount = 0, fieldCount, chevronPosition = 'right', icon, children }, ref) {
27
27
  const t = useT()
28
- const { expanded, toggle, setExpanded } = useGroupCollapse(pageType, groupId, defaultExpanded)
28
+ const { expanded, toggle, setExpanded, isHydrated } = useGroupCollapse(pageType, groupId, defaultExpanded)
29
29
  const contentId = `collapsible-group-${groupId}`
30
30
 
31
31
  React.useImperativeHandle(ref, () => ({
@@ -58,7 +58,17 @@ export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, Collaps
58
58
  ) : null
59
59
 
60
60
  return (
61
- <div className={cn('rounded-lg border bg-card', errorCount > 0 && 'border-destructive')}>
61
+ <div
62
+ id={`collapsible-group-wrapper-${groupId}`}
63
+ className={cn(
64
+ 'rounded-lg border bg-card',
65
+ !isHydrated && 'invisible',
66
+ errorCount > 0 && 'border-destructive',
67
+ )}
68
+ data-collapsible-group-id={groupId}
69
+ data-persistence-hydrated={isHydrated ? 'true' : 'false'}
70
+ aria-hidden={isHydrated ? undefined : true}
71
+ >
62
72
  {title && (
63
73
  <Button
64
74
  type="button"
@@ -4,6 +4,7 @@ import { ChevronsLeft, ChevronsRight } from 'lucide-react'
4
4
  import { useT } from '@open-mercato/shared/lib/i18n/context'
5
5
  import { cn } from '@open-mercato/shared/lib/utils'
6
6
  import { Button } from '../../primitives/button'
7
+ import { IconButton } from '../../primitives/icon-button'
7
8
  import { useZoneCollapse } from './useZoneCollapse'
8
9
  import type { LucideIcon } from 'lucide-react'
9
10
 
@@ -13,6 +14,8 @@ export interface ZoneSectionDescriptor {
13
14
  id: string
14
15
  icon: LucideIcon
15
16
  label: string
17
+ targetId?: string
18
+ ariaLabel?: string
16
19
  errorCount?: number
17
20
  }
18
21
 
@@ -53,7 +56,7 @@ export function CollapsibleZoneLayout({
53
56
  sections,
54
57
  }: CollapsibleZoneLayoutProps) {
55
58
  const t = useT()
56
- const { collapsed, setCollapsed } = useZoneCollapse(pageType)
59
+ const { collapsed, setCollapsed, isHydrated } = useZoneCollapse(pageType)
57
60
  const canCollapse = React.useSyncExternalStore(
58
61
  subscribeViewport,
59
62
  getViewportSnapshot,
@@ -114,6 +117,20 @@ export function CollapsibleZoneLayout({
114
117
  setExpandedWhileConstrained(!canShowSideBySide)
115
118
  }, [canCollapse, canShowSideBySide, setCollapsed])
116
119
 
120
+ const handleSectionActivate = React.useCallback((section: ZoneSectionDescriptor) => {
121
+ if (!canCollapse) return
122
+ setCollapsed(false)
123
+ setExpandedWhileConstrained(!canShowSideBySide)
124
+ requestAnimationFrame(() => {
125
+ const target =
126
+ document.getElementById(section.targetId ?? `collapsible-group-wrapper-${section.id}`)
127
+ ?? document.getElementById(`collapsible-group-${section.id}`)
128
+ const headingButton = target?.querySelector<HTMLButtonElement>('button[aria-controls]')
129
+ target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
130
+ headingButton?.focus({ preventScroll: true })
131
+ })
132
+ }, [canCollapse, canShowSideBySide, setCollapsed])
133
+
117
134
  const handleCollapse = React.useCallback(() => {
118
135
  if (!canCollapse) return
119
136
  setExpandedWhileConstrained(false)
@@ -127,8 +144,11 @@ export function CollapsibleZoneLayout({
127
144
  <div
128
145
  ref={layoutRef}
129
146
  data-zone-layout-mode={layoutMode}
147
+ data-persistence-hydrated={isHydrated ? 'true' : 'false'}
148
+ aria-hidden={isHydrated ? undefined : true}
130
149
  className={cn(
131
150
  'flex gap-4',
151
+ !isHydrated && 'invisible',
132
152
  showStackedExpanded ? 'flex-col' : 'flex-col lg:flex-row',
133
153
  )}
134
154
  >
@@ -152,16 +172,21 @@ export function CollapsibleZoneLayout({
152
172
  const SectionIcon = section.icon
153
173
  const hasErrors = Boolean(section.errorCount && section.errorCount > 0)
154
174
  return (
155
- <div
175
+ <IconButton
156
176
  key={section.id}
157
- className="relative flex size-9 items-center justify-center rounded-[10px] border border-transparent bg-muted/70 text-muted-foreground"
177
+ type="button"
178
+ variant="ghost"
179
+ size="default"
180
+ onClick={() => handleSectionActivate(section)}
181
+ className="relative size-9 rounded-[10px] border border-transparent bg-muted/70 text-muted-foreground hover:border-border hover:bg-accent hover:text-accent-foreground"
158
182
  title={section.label}
183
+ aria-label={section.ariaLabel ?? section.label}
159
184
  >
160
185
  <SectionIcon className="size-4" />
161
186
  {hasErrors ? (
162
187
  <span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-destructive" />
163
188
  ) : null}
164
- </div>
189
+ </IconButton>
165
190
  )
166
191
  })}
167
192
  </div>
@@ -1,5 +1,8 @@
1
1
  /** @jest-environment jsdom */
2
- import { act, renderHook } from '@testing-library/react'
2
+ import * as React from 'react'
3
+ import { renderToString } from 'react-dom/server'
4
+ import { hydrateRoot } from 'react-dom/client'
5
+ import { act, render, renderHook, waitFor } from '@testing-library/react'
3
6
  import { usePersistedBooleanFlag } from '../usePersistedBooleanFlag'
4
7
 
5
8
  describe('usePersistedBooleanFlag', () => {
@@ -39,11 +42,84 @@ describe('usePersistedBooleanFlag', () => {
39
42
  expect(localStorage.getItem('test:e')).toBe(JSON.stringify('0'))
40
43
  })
41
44
 
42
- it('writes initial value to storage on mount', () => {
43
- // React runs all effects on the same render cycle, so the "set mounted=true"
44
- // effect fires before the write effect checks mounted.current. The hook DOES
45
- // write the initial value on mount — this test locks in that observed behavior.
46
- renderHook(() => usePersistedBooleanFlag('test:f', true))
47
- expect(localStorage.getItem('test:f')).toBe(JSON.stringify('1'))
45
+ it('supports the functional setValue(prev => next) form', () => {
46
+ localStorage.setItem('test:functional', JSON.stringify('1'))
47
+ const { result } = renderHook(() => usePersistedBooleanFlag('test:functional', false))
48
+ act(() => { result.current.setValue((prev) => !prev) })
49
+ expect(result.current.value).toBe(false)
50
+ expect(localStorage.getItem('test:functional')).toBe(JSON.stringify('0'))
51
+ })
52
+
53
+ it('does NOT write the default value to storage when nothing was touched', () => {
54
+ renderHook(() => usePersistedBooleanFlag('test:no-write-on-mount', true))
55
+ expect(localStorage.getItem('test:no-write-on-mount')).toBeNull()
56
+ })
57
+
58
+ it('reflects stored value on the very first render (no useEffect flicker)', () => {
59
+ localStorage.setItem('test:first-render', JSON.stringify('1'))
60
+
61
+ const captured: boolean[] = []
62
+ function Probe() {
63
+ const { value } = usePersistedBooleanFlag('test:first-render', false)
64
+ captured.push(value)
65
+ return null
66
+ }
67
+ render(React.createElement(Probe))
68
+
69
+ expect(captured.length).toBeGreaterThanOrEqual(1)
70
+ expect(captured[0]).toBe(true)
71
+ expect(captured.every((v) => v === true)).toBe(true)
72
+ })
73
+
74
+ it('keeps SSR markup hidden until the client storage snapshot is known', async () => {
75
+ const storageKey = 'test:ssr-hidden'
76
+ function Probe() {
77
+ const { value, isHydrated } = usePersistedBooleanFlag(storageKey, false)
78
+ return React.createElement(
79
+ 'div',
80
+ {
81
+ className: isHydrated ? 'ready' : 'invisible',
82
+ 'data-ready': isHydrated ? 'true' : 'false',
83
+ 'data-testid': 'probe',
84
+ },
85
+ value ? 'stored' : 'default',
86
+ )
87
+ }
88
+
89
+ const html = renderToString(React.createElement(Probe))
90
+ expect(html).toContain('data-ready="false"')
91
+ expect(html).toContain('invisible')
92
+
93
+ const container = document.createElement('div')
94
+ container.innerHTML = html
95
+ document.body.appendChild(container)
96
+ localStorage.setItem(storageKey, JSON.stringify('1'))
97
+
98
+ let root: ReturnType<typeof hydrateRoot> | null = null
99
+ await act(async () => {
100
+ root = hydrateRoot(container, React.createElement(Probe))
101
+ })
102
+
103
+ await waitFor(() => {
104
+ const probe = container.querySelector('[data-testid="probe"]')
105
+ expect(probe?.getAttribute('data-ready')).toBe('true')
106
+ expect(probe?.className).not.toContain('invisible')
107
+ expect(probe?.textContent).toBe('stored')
108
+ })
109
+
110
+ await act(async () => {
111
+ root?.unmount()
112
+ })
113
+ container.remove()
114
+ })
115
+
116
+ it('re-renders when another instance writes the same key (same-tab sync)', () => {
117
+ const { result: a } = renderHook(() => usePersistedBooleanFlag('test:sync', false))
118
+ const { result: b } = renderHook(() => usePersistedBooleanFlag('test:sync', false))
119
+ expect(a.current.value).toBe(false)
120
+ expect(b.current.value).toBe(false)
121
+ act(() => { a.current.setValue(true) })
122
+ expect(a.current.value).toBe(true)
123
+ expect(b.current.value).toBe(true)
48
124
  })
49
125
  })