@open-mercato/ui 0.6.1-develop.3081.21270ec58a → 0.6.1-develop.3102.d6e7e6d57a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/confirm-dialog/useConfirmDialog.tsx"],
4
- "sourcesContent": ["\"use client\";\nimport * as React from \"react\";\nimport { ConfirmDialog } from \"./ConfirmDialog\";\n\nfunction DialogMountTracker({ trackerRef }: { trackerRef: React.MutableRefObject<boolean> }) {\n React.useEffect(() => {\n trackerRef.current = true;\n return () => {\n trackerRef.current = false;\n };\n }, [trackerRef]);\n return null;\n}\n\nexport type ConfirmDialogOptions = {\n title?: string;\n text?: string;\n description?: string;\n confirmText?: string | false;\n cancelText?: string | false;\n variant?: \"default\" | \"destructive\";\n};\n\nexport type UseConfirmDialogReturn = {\n /** Call this to show a confirmation dialog. Resolves `true` if confirmed, `false` if cancelled. */\n confirm: (options?: ConfirmDialogOptions) => Promise<boolean>;\n /** Render this in your component tree (renders the <dialog> element) */\n ConfirmDialogElement: React.ReactNode;\n};\n\nexport function useConfirmDialog(): UseConfirmDialogReturn {\n const [open, setOpen] = React.useState(false);\n const [options, setOptions] = React.useState<ConfirmDialogOptions>({});\n const [loading, setLoading] = React.useState(false);\n const resolveRef = React.useRef<((value: boolean) => void) | null>(null);\n const openRef = React.useRef(false);\n const isDialogElementRenderedRef = React.useRef(false);\n const queueRef = React.useRef<Array<{\n options?: ConfirmDialogOptions;\n resolve: (value: boolean) => void;\n }>>([]);\n\n const setOpenState = React.useCallback((nextOpen: boolean) => {\n openRef.current = nextOpen;\n setOpen(nextOpen);\n }, []);\n\n const processQueue = React.useCallback(() => {\n const next = queueRef.current.shift();\n if (!next) return;\n resolveRef.current = next.resolve;\n setOptions(next.options || {});\n setOpenState(true);\n }, [setOpenState]);\n const finalizeInteraction = React.useCallback(() => {\n setOpenState(false);\n processQueue();\n }, [processQueue, setOpenState]);\n\n const confirm = React.useCallback(\n (newOptions?: ConfirmDialogOptions): Promise<boolean> => {\n return new Promise<boolean>((resolve) => {\n // Development-mode guard: warn if dialog element is not mounted\n if (\n process.env.NODE_ENV === \"development\" &&\n !isDialogElementRenderedRef.current\n ) {\n console.warn(\n \"useConfirmDialog: confirm() was called but ConfirmDialogElement is not rendered. Add {ConfirmDialogElement} to your JSX.\"\n );\n }\n\n // If dialog is already open, queue this request\n if (openRef.current || resolveRef.current) {\n queueRef.current.push({ options: newOptions, resolve });\n return;\n }\n\n // Otherwise, show the dialog immediately\n resolveRef.current = resolve;\n setOptions(newOptions || {});\n setOpenState(true);\n });\n },\n [setOpenState]\n );\n\n const handleConfirm = React.useCallback(async () => {\n setLoading(true);\n try {\n // Resolve with true (confirmed)\n resolveRef.current?.(true);\n resolveRef.current = null;\n } finally {\n setLoading(false);\n finalizeInteraction();\n }\n }, [finalizeInteraction]);\n\n const handleCancel = React.useCallback(() => {\n // Resolve with false (cancelled)\n resolveRef.current?.(false);\n resolveRef.current = null;\n finalizeInteraction();\n }, [finalizeInteraction]);\n\n const handleOpenChange = React.useCallback(\n (newOpen: boolean) => {\n if (!newOpen && openRef.current) {\n handleCancel();\n }\n },\n [handleCancel]\n );\n\n const ConfirmDialogElement = React.useMemo(\n () => (\n <>\n <DialogMountTracker trackerRef={isDialogElementRenderedRef} />\n <ConfirmDialog\n open={open}\n onOpenChange={handleOpenChange}\n onConfirm={handleConfirm}\n title={options.title}\n text={options.text ?? options.description}\n confirmText={options.confirmText}\n cancelText={options.cancelText}\n variant={options.variant}\n loading={loading}\n />\n </>\n ),\n [\n open,\n handleOpenChange,\n handleConfirm,\n options,\n loading,\n ]\n );\n\n return {\n confirm,\n ConfirmDialogElement,\n };\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\";\nimport * as React from \"react\";\nimport { ConfirmDialog } from \"./ConfirmDialog\";\n\nfunction DialogMountTracker({ trackerRef }: { trackerRef: React.MutableRefObject<boolean> }) {\n React.useEffect(() => {\n trackerRef.current = true;\n return () => {\n trackerRef.current = false;\n };\n }, [trackerRef]);\n return null;\n}\n\nexport type ConfirmDialogOptions = {\n title?: string;\n text?: string;\n description?: string;\n confirmText?: string | false;\n cancelText?: string | false;\n variant?: \"default\" | \"destructive\";\n};\n\nexport type UseConfirmDialogReturn = {\n /** Call this to show a confirmation dialog. Resolves `true` if confirmed, `false` if cancelled. */\n confirm: (options?: ConfirmDialogOptions) => Promise<boolean>;\n /** Render this in your component tree (renders the <dialog> element) */\n ConfirmDialogElement: React.ReactNode;\n};\n\nexport function useConfirmDialog(): UseConfirmDialogReturn {\n const [open, setOpen] = React.useState(false);\n const [options, setOptions] = React.useState<ConfirmDialogOptions>({});\n const [loading, setLoading] = React.useState(false);\n const resolveRef = React.useRef<((value: boolean) => void) | null>(null);\n const openRef = React.useRef(false);\n const isDialogElementRenderedRef = React.useRef(false);\n const queueRef = React.useRef<Array<{\n options?: ConfirmDialogOptions;\n resolve: (value: boolean) => void;\n }>>([]);\n\n const processQueue = React.useCallback(() => {\n if (openRef.current) return;\n const next = queueRef.current.shift();\n if (!next) return;\n // Reserve the open slot synchronously so a confirm() call dispatched\n // between this microtask scheduling and the actual setState cannot\n // race ahead and double-open the dialog.\n openRef.current = true;\n resolveRef.current = next.resolve;\n // Defer the React state writes to a microtask so they run after any\n // parent component's useInsertionEffect commit phase (Radix Dialog,\n // CSS-in-JS layer injection, etc.). React 18/19 rejects setState\n // scheduled during the insertion-effect phase with the warning\n // \"useInsertionEffect must not schedule updates\" \u2014 see #1810.\n queueMicrotask(() => {\n setOptions(next.options || {});\n setOpen(true);\n });\n }, []);\n const finalizeInteraction = React.useCallback(() => {\n // Reset openRef BEFORE scheduling queue work so a subsequent confirm()\n // call from the parent's onOpenChange propagation isn't dropped by the\n // openRef.current === true guard (#1804). The visible dialog still\n // closes via setOpen(false); only the internal lock flips early.\n openRef.current = false;\n setOpen(false);\n // Defer queue advancement to the next microtask so the parent's\n // onOpenChange / Promise resolution has a chance to propagate before\n // a queued request reopens the dialog with new options.\n queueMicrotask(() => {\n processQueue();\n });\n }, [processQueue]);\n\n const confirm = React.useCallback(\n (newOptions?: ConfirmDialogOptions): Promise<boolean> => {\n return new Promise<boolean>((resolve) => {\n // Development-mode guard: warn if dialog element is not mounted\n if (\n process.env.NODE_ENV === \"development\" &&\n !isDialogElementRenderedRef.current\n ) {\n console.warn(\n \"useConfirmDialog: confirm() was called but ConfirmDialogElement is not rendered. Add {ConfirmDialogElement} to your JSX.\"\n );\n }\n\n // If dialog is already open or a previous interaction is still\n // resolving, enqueue this request. processQueue() picks it up after\n // the in-flight interaction finalises.\n if (openRef.current || resolveRef.current) {\n queueRef.current.push({ options: newOptions, resolve });\n return;\n }\n\n // Otherwise, claim the open slot synchronously and defer the\n // actual setState writes to a microtask (same rationale as\n // processQueue \u2014 keeps us out of the parent's insertion-effect\n // commit phase).\n openRef.current = true;\n resolveRef.current = resolve;\n queueMicrotask(() => {\n setOptions(newOptions || {});\n setOpen(true);\n });\n });\n },\n []\n );\n\n const handleConfirm = React.useCallback(async () => {\n setLoading(true);\n try {\n // Resolve with true (confirmed)\n resolveRef.current?.(true);\n resolveRef.current = null;\n } finally {\n setLoading(false);\n finalizeInteraction();\n }\n }, [finalizeInteraction]);\n\n const handleCancel = React.useCallback(() => {\n // Resolve with false (cancelled)\n resolveRef.current?.(false);\n resolveRef.current = null;\n finalizeInteraction();\n }, [finalizeInteraction]);\n\n const handleOpenChange = React.useCallback(\n (newOpen: boolean) => {\n if (!newOpen && openRef.current) {\n handleCancel();\n }\n },\n [handleCancel]\n );\n\n const ConfirmDialogElement = React.useMemo(\n () => (\n <>\n <DialogMountTracker trackerRef={isDialogElementRenderedRef} />\n <ConfirmDialog\n open={open}\n onOpenChange={handleOpenChange}\n onConfirm={handleConfirm}\n title={options.title}\n text={options.text ?? options.description}\n confirmText={options.confirmText}\n cancelText={options.cancelText}\n variant={options.variant}\n loading={loading}\n />\n </>\n ),\n [\n open,\n handleOpenChange,\n handleConfirm,\n options,\n loading,\n ]\n );\n\n return {\n confirm,\n ConfirmDialogElement,\n };\n}\n"],
5
+ "mappings": ";AA8IM,mBACE,KADF;AA7IN,YAAY,WAAW;AACvB,SAAS,qBAAqB;AAE9B,SAAS,mBAAmB,EAAE,WAAW,GAAoD;AAC3F,QAAM,UAAU,MAAM;AACpB,eAAW,UAAU;AACrB,WAAO,MAAM;AACX,iBAAW,UAAU;AAAA,IACvB;AAAA,EACF,GAAG,CAAC,UAAU,CAAC;AACf,SAAO;AACT;AAkBO,SAAS,mBAA2C;AACzD,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,KAAK;AAC5C,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAA+B,CAAC,CAAC;AACrE,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAClD,QAAM,aAAa,MAAM,OAA0C,IAAI;AACvE,QAAM,UAAU,MAAM,OAAO,KAAK;AAClC,QAAM,6BAA6B,MAAM,OAAO,KAAK;AACrD,QAAM,WAAW,MAAM,OAGnB,CAAC,CAAC;AAEN,QAAM,eAAe,MAAM,YAAY,MAAM;AAC3C,QAAI,QAAQ,QAAS;AACrB,UAAM,OAAO,SAAS,QAAQ,MAAM;AACpC,QAAI,CAAC,KAAM;AAIX,YAAQ,UAAU;AAClB,eAAW,UAAU,KAAK;AAM1B,mBAAe,MAAM;AACnB,iBAAW,KAAK,WAAW,CAAC,CAAC;AAC7B,cAAQ,IAAI;AAAA,IACd,CAAC;AAAA,EACH,GAAG,CAAC,CAAC;AACL,QAAM,sBAAsB,MAAM,YAAY,MAAM;AAKlD,YAAQ,UAAU;AAClB,YAAQ,KAAK;AAIb,mBAAe,MAAM;AACnB,mBAAa;AAAA,IACf,CAAC;AAAA,EACH,GAAG,CAAC,YAAY,CAAC;AAEjB,QAAM,UAAU,MAAM;AAAA,IACpB,CAAC,eAAwD;AACvD,aAAO,IAAI,QAAiB,CAAC,YAAY;AAEvC,YACE,QAAQ,IAAI,aAAa,iBACzB,CAAC,2BAA2B,SAC5B;AACA,kBAAQ;AAAA,YACN;AAAA,UACF;AAAA,QACF;AAKA,YAAI,QAAQ,WAAW,WAAW,SAAS;AACzC,mBAAS,QAAQ,KAAK,EAAE,SAAS,YAAY,QAAQ,CAAC;AACtD;AAAA,QACF;AAMA,gBAAQ,UAAU;AAClB,mBAAW,UAAU;AACrB,uBAAe,MAAM;AACnB,qBAAW,cAAc,CAAC,CAAC;AAC3B,kBAAQ,IAAI;AAAA,QACd,CAAC;AAAA,MACH,CAAC;AAAA,IACH;AAAA,IACA,CAAC;AAAA,EACH;AAEA,QAAM,gBAAgB,MAAM,YAAY,YAAY;AAClD,eAAW,IAAI;AACf,QAAI;AAEF,iBAAW,UAAU,IAAI;AACzB,iBAAW,UAAU;AAAA,IACvB,UAAE;AACA,iBAAW,KAAK;AAChB,0BAAoB;AAAA,IACtB;AAAA,EACF,GAAG,CAAC,mBAAmB,CAAC;AAExB,QAAM,eAAe,MAAM,YAAY,MAAM;AAE3C,eAAW,UAAU,KAAK;AAC1B,eAAW,UAAU;AACrB,wBAAoB;AAAA,EACtB,GAAG,CAAC,mBAAmB,CAAC;AAExB,QAAM,mBAAmB,MAAM;AAAA,IAC7B,CAAC,YAAqB;AACpB,UAAI,CAAC,WAAW,QAAQ,SAAS;AAC/B,qBAAa;AAAA,MACf;AAAA,IACF;AAAA,IACA,CAAC,YAAY;AAAA,EACf;AAEA,QAAM,uBAAuB,MAAM;AAAA,IACjC,MACE,iCACE;AAAA,0BAAC,sBAAmB,YAAY,4BAA4B;AAAA,MAC5D;AAAA,QAAC;AAAA;AAAA,UACC;AAAA,UACA,cAAc;AAAA,UACd,WAAW;AAAA,UACX,OAAO,QAAQ;AAAA,UACf,MAAM,QAAQ,QAAQ,QAAQ;AAAA,UAC9B,aAAa,QAAQ;AAAA,UACrB,YAAY,QAAQ;AAAA,UACpB,SAAS,QAAQ;AAAA,UACjB;AAAA;AAAA,MACF;AAAA,OACF;AAAA,IAEF;AAAA,MACE;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -6,12 +6,15 @@ import { Input } from "../../primitives/input.js";
6
6
  const DEFAULT_MIN_DIGITS = 6;
7
7
  const DEFAULT_INVALID_LABEL = "Enter a valid phone number with country code (e.g. +1 212 555 1234)";
8
8
  function PhoneNumberField({
9
+ id,
9
10
  value,
10
11
  onValueChange,
11
12
  onDigitsChange,
12
13
  externalError,
13
14
  disabled = false,
14
15
  autoFocus,
16
+ ariaLabel,
17
+ ariaDescribedBy,
15
18
  placeholder,
16
19
  minDigits = DEFAULT_MIN_DIGITS,
17
20
  checkingLabel,
@@ -28,7 +31,10 @@ function PhoneNumberField({
28
31
  const [checking, setChecking] = React.useState(false);
29
32
  const [validationHint, setValidationHint] = React.useState(null);
30
33
  const userEditingRef = React.useRef(false);
31
- const visibleValidationHint = externalError ? null : validationHint;
34
+ const externalFieldError = externalError && externalError.trim().length > 0 ? externalError : null;
35
+ const errorMessage = externalFieldError ?? validationHint;
36
+ const errorId = errorMessage && id ? `${id}-error` : void 0;
37
+ const describedBy = [ariaDescribedBy, errorId].filter((part) => Boolean(part)).join(" ") || void 0;
32
38
  React.useEffect(() => {
33
39
  if (userEditingRef.current) return;
34
40
  if (value == null || value === "") {
@@ -115,10 +121,14 @@ function PhoneNumberField({
115
121
  placeholder,
116
122
  autoFocus,
117
123
  disabled,
124
+ id,
125
+ "aria-label": ariaLabel,
126
+ "aria-describedby": describedBy,
127
+ "aria-invalid": errorMessage ? "true" : void 0,
118
128
  "data-crud-focus-target": ""
119
129
  }
120
130
  ),
121
- visibleValidationHint ? /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", children: visibleValidationHint }) : null,
131
+ errorMessage ? /* @__PURE__ */ jsx("p", { id: errorId, className: "text-xs text-destructive", children: errorMessage }) : null,
122
132
  !disabled && duplicate && duplicateLabel && duplicateLinkLabel ? /* @__PURE__ */ jsxs("p", { className: "text-xs text-amber-600", children: [
123
133
  duplicateLabel(duplicate),
124
134
  " ",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/inputs/PhoneNumberField.tsx"],
4
- "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { extractPhoneDigits, validatePhoneNumber } from '@open-mercato/shared/lib/phone'\nimport { Input } from '../../primitives/input'\n\nexport type PhoneDuplicateMatch = {\n id: string\n label: string\n href: string\n}\n\nexport type PhoneNumberFieldProps = {\n value?: string | null\n onValueChange: (next: string | undefined) => void\n onDigitsChange?: (digits: string | null) => void\n externalError?: string | null\n disabled?: boolean\n autoFocus?: boolean\n placeholder?: string\n minDigits?: number\n checkingLabel?: string\n duplicateLabel?: (match: PhoneDuplicateMatch) => string\n duplicateLinkLabel?: string\n invalidLabel?: string\n onDuplicateLookup?: (normalizedValue: string) => Promise<PhoneDuplicateMatch | null>\n}\n\nconst DEFAULT_MIN_DIGITS = 6\nconst DEFAULT_INVALID_LABEL = 'Enter a valid phone number with country code (e.g. +1 212 555 1234)'\n\nexport function PhoneNumberField({\n value,\n onValueChange,\n onDigitsChange,\n externalError,\n disabled = false,\n autoFocus,\n placeholder,\n minDigits = DEFAULT_MIN_DIGITS,\n checkingLabel,\n duplicateLabel,\n duplicateLinkLabel,\n invalidLabel,\n onDuplicateLookup,\n}: PhoneNumberFieldProps) {\n const [local, setLocal] = React.useState<string>(() => {\n if (value == null || value === '') return ''\n return String(value)\n })\n const [duplicate, setDuplicate] = React.useState<PhoneDuplicateMatch | null>(null)\n const [checking, setChecking] = React.useState(false)\n const [validationHint, setValidationHint] = React.useState<string | null>(null)\n const userEditingRef = React.useRef(false)\n const visibleValidationHint = externalError ? null : validationHint\n\n React.useEffect(() => {\n if (userEditingRef.current) return\n if (value == null || value === '') {\n setLocal('')\n onDigitsChange?.(null)\n return\n }\n const nextValue = String(value)\n setLocal(nextValue)\n onDigitsChange?.(extractPhoneDigits(nextValue) || null)\n }, [value, onDigitsChange])\n\n React.useEffect(() => {\n if (!onDuplicateLookup || disabled) {\n setDuplicate(null)\n setChecking(false)\n return\n }\n const digits = extractPhoneDigits(local)\n if (!digits || digits.length < minDigits) {\n setDuplicate(null)\n setChecking(false)\n return\n }\n\n let cancelled = false\n setChecking(true)\n const handle = window.setTimeout(async () => {\n try {\n const match = await onDuplicateLookup(digits)\n if (!cancelled) setDuplicate(match)\n } catch {\n if (!cancelled) setDuplicate(null)\n } finally {\n if (!cancelled) setChecking(false)\n }\n }, 350)\n\n return () => {\n cancelled = true\n window.clearTimeout(handle)\n }\n }, [local, disabled, minDigits, onDuplicateLookup])\n\n const handleChange = React.useCallback(\n (event: React.ChangeEvent<HTMLInputElement>) => {\n const next = event.target.value\n const cleanDigits = extractPhoneDigits(next)\n userEditingRef.current = true\n setLocal(next)\n setValidationHint(null)\n onValueChange(next.length ? next : undefined)\n onDigitsChange?.(cleanDigits.length ? cleanDigits : null)\n },\n [onValueChange, onDigitsChange]\n )\n\n const handleBlur = React.useCallback(() => {\n userEditingRef.current = false\n const trimmed = local.trim()\n if (!trimmed) {\n setLocal('')\n setValidationHint(null)\n onValueChange(undefined)\n onDigitsChange?.(null)\n return\n }\n const result = validatePhoneNumber(trimmed)\n if (result.valid) {\n setLocal(result.normalized ?? '')\n setValidationHint(null)\n onValueChange(result.normalized || undefined)\n onDigitsChange?.(result.digits || null)\n } else {\n setLocal(trimmed)\n setValidationHint(invalidLabel ?? DEFAULT_INVALID_LABEL)\n onValueChange(trimmed)\n onDigitsChange?.(result.digits || null)\n }\n }, [invalidLabel, local, onDigitsChange, onValueChange])\n\n return (\n <div className=\"space-y-2\">\n <Input\n type=\"tel\"\n value={local}\n onChange={handleChange}\n onBlur={handleBlur}\n placeholder={placeholder}\n autoFocus={autoFocus}\n disabled={disabled}\n data-crud-focus-target=\"\"\n />\n {visibleValidationHint ? (\n <p className=\"text-xs text-destructive\">{visibleValidationHint}</p>\n ) : null}\n {!disabled && duplicate && duplicateLabel && duplicateLinkLabel ? (\n <p className=\"text-xs text-amber-600\">\n {duplicateLabel(duplicate)}{' '}\n <a className=\"font-medium text-primary underline underline-offset-2\" href={duplicate.href}>\n {duplicateLinkLabel}\n </a>\n </p>\n ) : null}\n {!disabled && !duplicate && checking && checkingLabel ? (\n <p className=\"text-xs text-muted-foreground\">{checkingLabel}</p>\n ) : null}\n </div>\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { extractPhoneDigits, validatePhoneNumber } from '@open-mercato/shared/lib/phone'\nimport { Input } from '../../primitives/input'\n\nexport type PhoneDuplicateMatch = {\n id: string\n label: string\n href: string\n}\n\nexport type PhoneNumberFieldProps = {\n id?: string\n value?: string | null\n onValueChange: (next: string | undefined) => void\n onDigitsChange?: (digits: string | null) => void\n externalError?: string | null\n disabled?: boolean\n autoFocus?: boolean\n ariaLabel?: string\n ariaDescribedBy?: string\n placeholder?: string\n minDigits?: number\n checkingLabel?: string\n duplicateLabel?: (match: PhoneDuplicateMatch) => string\n duplicateLinkLabel?: string\n invalidLabel?: string\n onDuplicateLookup?: (normalizedValue: string) => Promise<PhoneDuplicateMatch | null>\n}\n\nconst DEFAULT_MIN_DIGITS = 6\nconst DEFAULT_INVALID_LABEL = 'Enter a valid phone number with country code (e.g. +1 212 555 1234)'\n\nexport function PhoneNumberField({\n id,\n value,\n onValueChange,\n onDigitsChange,\n externalError,\n disabled = false,\n autoFocus,\n ariaLabel,\n ariaDescribedBy,\n placeholder,\n minDigits = DEFAULT_MIN_DIGITS,\n checkingLabel,\n duplicateLabel,\n duplicateLinkLabel,\n invalidLabel,\n onDuplicateLookup,\n}: PhoneNumberFieldProps) {\n const [local, setLocal] = React.useState<string>(() => {\n if (value == null || value === '') return ''\n return String(value)\n })\n const [duplicate, setDuplicate] = React.useState<PhoneDuplicateMatch | null>(null)\n const [checking, setChecking] = React.useState(false)\n const [validationHint, setValidationHint] = React.useState<string | null>(null)\n const userEditingRef = React.useRef(false)\n const externalFieldError = externalError && externalError.trim().length > 0 ? externalError : null\n const errorMessage = externalFieldError ?? validationHint\n const errorId = errorMessage && id ? `${id}-error` : undefined\n const describedBy = [ariaDescribedBy, errorId].filter((part): part is string => Boolean(part)).join(' ') || undefined\n\n React.useEffect(() => {\n if (userEditingRef.current) return\n if (value == null || value === '') {\n setLocal('')\n onDigitsChange?.(null)\n return\n }\n const nextValue = String(value)\n setLocal(nextValue)\n onDigitsChange?.(extractPhoneDigits(nextValue) || null)\n }, [value, onDigitsChange])\n\n React.useEffect(() => {\n if (!onDuplicateLookup || disabled) {\n setDuplicate(null)\n setChecking(false)\n return\n }\n const digits = extractPhoneDigits(local)\n if (!digits || digits.length < minDigits) {\n setDuplicate(null)\n setChecking(false)\n return\n }\n\n let cancelled = false\n setChecking(true)\n const handle = window.setTimeout(async () => {\n try {\n const match = await onDuplicateLookup(digits)\n if (!cancelled) setDuplicate(match)\n } catch {\n if (!cancelled) setDuplicate(null)\n } finally {\n if (!cancelled) setChecking(false)\n }\n }, 350)\n\n return () => {\n cancelled = true\n window.clearTimeout(handle)\n }\n }, [local, disabled, minDigits, onDuplicateLookup])\n\n const handleChange = React.useCallback(\n (event: React.ChangeEvent<HTMLInputElement>) => {\n const next = event.target.value\n const cleanDigits = extractPhoneDigits(next)\n userEditingRef.current = true\n setLocal(next)\n setValidationHint(null)\n onValueChange(next.length ? next : undefined)\n onDigitsChange?.(cleanDigits.length ? cleanDigits : null)\n },\n [onValueChange, onDigitsChange]\n )\n\n const handleBlur = React.useCallback(() => {\n userEditingRef.current = false\n const trimmed = local.trim()\n if (!trimmed) {\n setLocal('')\n setValidationHint(null)\n onValueChange(undefined)\n onDigitsChange?.(null)\n return\n }\n const result = validatePhoneNumber(trimmed)\n if (result.valid) {\n setLocal(result.normalized ?? '')\n setValidationHint(null)\n onValueChange(result.normalized || undefined)\n onDigitsChange?.(result.digits || null)\n } else {\n setLocal(trimmed)\n setValidationHint(invalidLabel ?? DEFAULT_INVALID_LABEL)\n onValueChange(trimmed)\n onDigitsChange?.(result.digits || null)\n }\n }, [invalidLabel, local, onDigitsChange, onValueChange])\n\n return (\n <div className=\"space-y-2\">\n <Input\n type=\"tel\"\n value={local}\n onChange={handleChange}\n onBlur={handleBlur}\n placeholder={placeholder}\n autoFocus={autoFocus}\n disabled={disabled}\n id={id}\n aria-label={ariaLabel}\n aria-describedby={describedBy}\n aria-invalid={errorMessage ? 'true' : undefined}\n data-crud-focus-target=\"\"\n />\n {errorMessage ? (\n <p id={errorId} className=\"text-xs text-destructive\">{errorMessage}</p>\n ) : null}\n {!disabled && duplicate && duplicateLabel && duplicateLinkLabel ? (\n <p className=\"text-xs text-amber-600\">\n {duplicateLabel(duplicate)}{' '}\n <a className=\"font-medium text-primary underline underline-offset-2\" href={duplicate.href}>\n {duplicateLinkLabel}\n </a>\n </p>\n ) : null}\n {!disabled && !duplicate && checking && checkingLabel ? (\n <p className=\"text-xs text-muted-foreground\">{checkingLabel}</p>\n ) : null}\n </div>\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.1-develop.3081.21270ec58a",
3
+ "version": "0.6.1-develop.3102.d6e7e6d57a",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -140,12 +140,12 @@
140
140
  "remark-gfm": "^4.0.1"
141
141
  },
142
142
  "peerDependencies": {
143
- "@open-mercato/shared": "0.6.1-develop.3081.21270ec58a",
143
+ "@open-mercato/shared": "0.6.1-develop.3102.d6e7e6d57a",
144
144
  "react": ">=18.0.0",
145
145
  "react-dom": ">=18.0.0"
146
146
  },
147
147
  "devDependencies": {
148
- "@open-mercato/shared": "0.6.1-develop.3081.21270ec58a",
148
+ "@open-mercato/shared": "0.6.1-develop.3102.d6e7e6d57a",
149
149
  "@testing-library/dom": "^10.4.1",
150
150
  "@testing-library/jest-dom": "^6.9.1",
151
151
  "@testing-library/react": "^16.3.1",
@@ -454,6 +454,35 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
454
454
  // up via the aside ref so we don't have to thread refs through the JSX tree.
455
455
  const sidebarAsideRef = React.useRef<HTMLElement>(null)
456
456
  const [sidebarScrollState, setSidebarScrollState] = React.useState<'down' | 'up' | 'none'>('down')
457
+ const sidebarScrollIntentRef = React.useRef<'top' | 'bottom' | null>(null)
458
+
459
+ // Click-to-scroll handler for the sidebar affordance chevron (#1803). Resolves the
460
+ // scroll target lazily through the aside ref so we don't have to thread refs into
461
+ // renderSidebar; respects `prefers-reduced-motion` by falling back to instant
462
+ // scrolling when the user has opted out of smooth motion.
463
+ const handleSidebarChevronScroll = React.useCallback((target: 'top' | 'bottom') => {
464
+ const aside = sidebarAsideRef.current
465
+ if (!aside) return
466
+ const scrollTarget = aside.querySelector<HTMLElement>('[data-sidebar-scroll="true"]')
467
+ if (!scrollTarget) return
468
+ const prefersReducedMotion =
469
+ typeof window !== 'undefined' &&
470
+ typeof window.matchMedia === 'function' &&
471
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches
472
+ const behavior: ScrollBehavior = prefersReducedMotion ? 'auto' : 'smooth'
473
+ const maxScrollTop = Math.max(0, scrollTarget.scrollHeight - scrollTarget.clientHeight)
474
+ if (maxScrollTop <= 1) {
475
+ sidebarScrollIntentRef.current = null
476
+ setSidebarScrollState('none')
477
+ return
478
+ }
479
+ sidebarScrollIntentRef.current = target
480
+ setSidebarScrollState(target === 'bottom' ? 'up' : 'down')
481
+ scrollTarget.scrollTo({
482
+ top: target === 'top' ? 0 : maxScrollTop,
483
+ behavior,
484
+ })
485
+ }, [])
457
486
  React.useEffect(() => {
458
487
  const aside = sidebarAsideRef.current
459
488
  if (!aside) return
@@ -463,10 +492,24 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
463
492
  const { scrollTop, scrollHeight, clientHeight } = target
464
493
  const canScroll = scrollHeight > clientHeight + 1
465
494
  if (!canScroll) {
495
+ sidebarScrollIntentRef.current = null
466
496
  setSidebarScrollState('none')
467
497
  return
468
498
  }
469
- const atBottom = scrollTop + clientHeight >= scrollHeight - 8
499
+ const maxScrollTop = Math.max(0, scrollHeight - clientHeight)
500
+ const atTop = scrollTop <= 8
501
+ const atBottom = scrollTop >= maxScrollTop - 8
502
+ const scrollIntent = sidebarScrollIntentRef.current
503
+ if (scrollIntent === 'bottom') {
504
+ if (atBottom) sidebarScrollIntentRef.current = null
505
+ setSidebarScrollState('up')
506
+ return
507
+ }
508
+ if (scrollIntent === 'top') {
509
+ if (atTop) sidebarScrollIntentRef.current = null
510
+ setSidebarScrollState('down')
511
+ return
512
+ }
470
513
  setSidebarScrollState(atBottom ? 'up' : 'down')
471
514
  }
472
515
  update()
@@ -1127,20 +1170,40 @@ function AppShellBody({ productName, logo, email, groups, rightHeaderSlot, child
1127
1170
  {/* Desktop main sidebar */}
1128
1171
  <aside ref={sidebarAsideRef} className={`${asideClassesBase} ${effectiveCollapsed ? 'px-2' : 'px-3'} hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative transition-[width,padding] duration-200 ease-out`} style={{ width: asideWidth }}>
1129
1172
  {renderSidebar(effectiveCollapsed, false, isSectionView)}
1130
- {/* Scroll affordance — gradient fade + chevron that flips up when the user
1131
- reaches the bottom and disappears when nothing is scrollable. */}
1173
+ {/* Scroll affordance — gradient fade + clickable chevron that flips up when
1174
+ the user reaches the bottom and disappears when nothing is scrollable
1175
+ (#1803). Clicking the chevron scrolls the inner sidebar container to
1176
+ top/bottom (`prefers-reduced-motion: reduce` collapses to instant
1177
+ scrolling). The wrapper is `pointer-events-none` so the gradient fade
1178
+ doesn't block hover/click on the rendered nav items behind it; the
1179
+ IconButton restores `pointer-events-auto` so it stays interactive. */}
1132
1180
  {sidebarScrollState !== 'none' ? (
1133
1181
  <div
1134
- aria-hidden
1135
1182
  className="pointer-events-none absolute inset-x-0 bottom-0 flex h-10 items-end justify-center bg-gradient-to-t from-background via-background/80 to-transparent pb-1.5"
1136
1183
  >
1137
- {/* Outer div owns the rotate transition so it doesn't fight with the
1138
- animate-bounce keyframes (both target `transform`). */}
1139
- <span
1140
- className={`inline-flex transition-transform duration-300 ${sidebarScrollState === 'up' ? 'rotate-180' : ''}`}
1184
+ {/* The IconButton owns hover/focus affordance; the inner span owns the
1185
+ rotate transition so it doesn't fight with the animate-bounce
1186
+ keyframes (both target `transform`). */}
1187
+ <IconButton
1188
+ type="button"
1189
+ variant="ghost"
1190
+ size="sm"
1191
+ data-testid="sidebar-scroll-chevron"
1192
+ data-sidebar-scroll-chevron={sidebarScrollState}
1193
+ aria-label={
1194
+ sidebarScrollState === 'up'
1195
+ ? t('ui.sidebar.chevron.scrollTop', 'Scroll to top')
1196
+ : t('ui.sidebar.chevron.scrollBottom', 'Scroll to bottom')
1197
+ }
1198
+ className="pointer-events-auto text-muted-foreground/70 hover:text-foreground"
1199
+ onClick={() => handleSidebarChevronScroll(sidebarScrollState === 'up' ? 'top' : 'bottom')}
1141
1200
  >
1142
- <ChevronDown className="size-4 animate-bounce text-muted-foreground/70" />
1143
- </span>
1201
+ <span
1202
+ className={`inline-flex transition-transform duration-300 ${sidebarScrollState === 'up' ? 'rotate-180' : ''}`}
1203
+ >
1204
+ <ChevronDown className="size-4 animate-bounce" />
1205
+ </span>
1206
+ </IconButton>
1144
1207
  </div>
1145
1208
  ) : null}
1146
1209
  </aside>
@@ -0,0 +1,250 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import * as React from 'react'
5
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
6
+ import { act, screen, waitFor } from '@testing-library/react'
7
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
+ import { useConfirmDialog } from '../useConfirmDialog'
9
+ import type { ConfirmDialogOptions } from '../useConfirmDialog'
10
+
11
+ function installDialogPolyfill() {
12
+ Object.defineProperty(HTMLDialogElement.prototype, 'showModal', {
13
+ configurable: true,
14
+ value(this: HTMLDialogElement) {
15
+ this.setAttribute('open', '')
16
+ },
17
+ })
18
+ Object.defineProperty(HTMLDialogElement.prototype, 'close', {
19
+ configurable: true,
20
+ value(this: HTMLDialogElement) {
21
+ this.removeAttribute('open')
22
+ },
23
+ })
24
+ }
25
+
26
+ type ConfirmFn = (options?: ConfirmDialogOptions) => Promise<boolean>
27
+
28
+ type HookHandle = {
29
+ current: { confirm: ConfirmFn } | null
30
+ }
31
+
32
+ function HookProbe({ handle }: { handle: HookHandle }) {
33
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
34
+ React.useEffect(() => {
35
+ handle.current = { confirm }
36
+ return () => {
37
+ handle.current = null
38
+ }
39
+ }, [confirm, handle])
40
+ return <>{ConfirmDialogElement}</>
41
+ }
42
+
43
+ function NestedRadixHost({ handle }: { handle: HookHandle }) {
44
+ // Mirrors ScheduleActivityDialog: ConfirmDialogElement is mounted inside a
45
+ // Radix Dialog. Radix uses useInsertionEffect for style injection on
46
+ // open transitions; setState scheduled mid-commit by useConfirmDialog
47
+ // surfaces the React 19 "useInsertionEffect must not schedule updates"
48
+ // warning unless the hook defers its writes.
49
+ return (
50
+ <DialogPrimitive.Root open>
51
+ <DialogPrimitive.Portal>
52
+ <DialogPrimitive.Overlay />
53
+ <DialogPrimitive.Content>
54
+ <DialogPrimitive.Title>Host dialog</DialogPrimitive.Title>
55
+ <HookProbe handle={handle} />
56
+ </DialogPrimitive.Content>
57
+ </DialogPrimitive.Portal>
58
+ </DialogPrimitive.Root>
59
+ )
60
+ }
61
+
62
+ async function flushMicrotasks() {
63
+ await act(async () => {
64
+ await Promise.resolve()
65
+ await Promise.resolve()
66
+ })
67
+ }
68
+
69
+ describe('useConfirmDialog — queue/timing fixes (#1804, #1810)', () => {
70
+ let consoleErrorSpy: jest.SpyInstance
71
+ let warnings: string[] = []
72
+
73
+ beforeEach(() => {
74
+ installDialogPolyfill()
75
+ warnings = []
76
+ consoleErrorSpy = jest
77
+ .spyOn(console, 'error')
78
+ .mockImplementation((...args: unknown[]) => {
79
+ warnings.push(args.map((arg) => String(arg)).join(' '))
80
+ })
81
+ })
82
+
83
+ afterEach(() => {
84
+ consoleErrorSpy.mockRestore()
85
+ })
86
+
87
+ it('does not log "useInsertionEffect must not schedule updates" when invoked inside a Radix Dialog (#1810)', async () => {
88
+ const handle: HookHandle = { current: null }
89
+ renderWithProviders(<NestedRadixHost handle={handle} />)
90
+
91
+ expect(handle.current).not.toBeNull()
92
+
93
+ let pending: Promise<boolean> | null = null
94
+ await act(async () => {
95
+ pending = handle.current!.confirm({
96
+ title: 'Discard unsaved changes?',
97
+ text: 'Confirm cancel',
98
+ cancelText: 'Keep editing',
99
+ confirmText: 'Discard',
100
+ })
101
+ })
102
+ await flushMicrotasks()
103
+
104
+ expect(pending).not.toBeNull()
105
+ expect(
106
+ warnings.some((entry) =>
107
+ entry.includes('useInsertionEffect must not schedule updates'),
108
+ ),
109
+ ).toBe(false)
110
+ })
111
+
112
+ it('does not log "useInsertionEffect must not schedule updates" when confirm() is invoked from an insertion effect (#1810)', async () => {
113
+ const handle: HookHandle = { current: null }
114
+
115
+ function HostInsertion() {
116
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
117
+ // Invoking confirm from useInsertionEffect models the React 19
118
+ // commit-phase race that surfaces #1810 in production: the hook
119
+ // schedules state updates synchronously while the parent's
120
+ // insertion-effect commit is still running.
121
+ React.useInsertionEffect(() => {
122
+ handle.current = { confirm }
123
+ }, [confirm])
124
+ // Once mounted, fire confirm in a layout effect so React 19 has
125
+ // rendered + committed the insertion phase. Then invoke it again
126
+ // from a microtask to stress the queue path.
127
+ React.useLayoutEffect(() => {
128
+ void confirm({ cancelText: 'Cancel', confirmText: 'OK' }).catch(() => undefined)
129
+ }, [confirm])
130
+ return <>{ConfirmDialogElement}</>
131
+ }
132
+
133
+ await act(async () => {
134
+ renderWithProviders(<HostInsertion />)
135
+ })
136
+ await flushMicrotasks()
137
+
138
+ expect(
139
+ warnings.some((entry) =>
140
+ entry.includes('useInsertionEffect must not schedule updates'),
141
+ ),
142
+ ).toBe(false)
143
+ })
144
+
145
+ it('reopens after Keep editing / X cancel: a second confirm() resolves and is not dropped (#1804)', async () => {
146
+ const handle: HookHandle = { current: null }
147
+ renderWithProviders(<HookProbe handle={handle} />)
148
+
149
+ expect(handle.current).not.toBeNull()
150
+
151
+ // First confirm — open and cancel via the dialog's Cancel ("Keep editing") button.
152
+ let firstResult: boolean | null = null
153
+ await act(async () => {
154
+ handle
155
+ .current!.confirm({
156
+ cancelText: 'Keep editing',
157
+ confirmText: 'Discard',
158
+ })
159
+ .then((value) => {
160
+ firstResult = value
161
+ })
162
+ })
163
+ await flushMicrotasks()
164
+
165
+ const cancelButton = await screen.findByRole('button', { name: 'Keep editing' })
166
+ await act(async () => {
167
+ cancelButton.click()
168
+ })
169
+ await flushMicrotasks()
170
+
171
+ expect(firstResult).toBe(false)
172
+
173
+ // Second confirm — must open again. Before the fix, openRef.current was not
174
+ // reset before processQueue ran, so the next confirm() was dropped.
175
+ let secondResult: boolean | null = null
176
+ await act(async () => {
177
+ handle
178
+ .current!.confirm({
179
+ cancelText: 'Keep editing',
180
+ confirmText: 'Discard',
181
+ })
182
+ .then((value) => {
183
+ secondResult = value
184
+ })
185
+ })
186
+ await flushMicrotasks()
187
+
188
+ const cancelButton2 = await screen.findByRole('button', { name: 'Keep editing' })
189
+ await act(async () => {
190
+ cancelButton2.click()
191
+ })
192
+ await flushMicrotasks()
193
+
194
+ expect(secondResult).toBe(false)
195
+ })
196
+
197
+ it('drains queued requests in declared order; duplicate confirm() during drain stays queued', async () => {
198
+ const handle: HookHandle = { current: null }
199
+ renderWithProviders(<HookProbe handle={handle} />)
200
+
201
+ expect(handle.current).not.toBeNull()
202
+
203
+ const order: string[] = []
204
+
205
+ // Three concurrent confirm() calls — first opens, others queue.
206
+ await act(async () => {
207
+ handle
208
+ .current!.confirm({ cancelText: 'Cancel A', confirmText: 'Confirm A' })
209
+ .then((value) => {
210
+ order.push(`a:${value}`)
211
+ })
212
+ handle
213
+ .current!.confirm({ cancelText: 'Cancel B', confirmText: 'Confirm B' })
214
+ .then((value) => {
215
+ order.push(`b:${value}`)
216
+ })
217
+ handle
218
+ .current!.confirm({ cancelText: 'Cancel C', confirmText: 'Confirm C' })
219
+ .then((value) => {
220
+ order.push(`c:${value}`)
221
+ })
222
+ })
223
+ await flushMicrotasks()
224
+
225
+ // First dialog (A) is the visible one.
226
+ const cancelA = await screen.findByRole('button', { name: 'Cancel A' })
227
+ await act(async () => {
228
+ cancelA.click()
229
+ })
230
+ await flushMicrotasks()
231
+
232
+ // Now B should have surfaced.
233
+ const cancelB = await screen.findByRole('button', { name: 'Cancel B' })
234
+ await act(async () => {
235
+ cancelB.click()
236
+ })
237
+ await flushMicrotasks()
238
+
239
+ // Now C should have surfaced.
240
+ const cancelC = await screen.findByRole('button', { name: 'Cancel C' })
241
+ await act(async () => {
242
+ cancelC.click()
243
+ })
244
+ await flushMicrotasks()
245
+
246
+ await waitFor(() => {
247
+ expect(order).toEqual(['a:false', 'b:false', 'c:false'])
248
+ })
249
+ })
250
+ })
@@ -40,22 +40,39 @@ export function useConfirmDialog(): UseConfirmDialogReturn {
40
40
  resolve: (value: boolean) => void;
41
41
  }>>([]);
42
42
 
43
- const setOpenState = React.useCallback((nextOpen: boolean) => {
44
- openRef.current = nextOpen;
45
- setOpen(nextOpen);
46
- }, []);
47
-
48
43
  const processQueue = React.useCallback(() => {
44
+ if (openRef.current) return;
49
45
  const next = queueRef.current.shift();
50
46
  if (!next) return;
47
+ // Reserve the open slot synchronously so a confirm() call dispatched
48
+ // between this microtask scheduling and the actual setState cannot
49
+ // race ahead and double-open the dialog.
50
+ openRef.current = true;
51
51
  resolveRef.current = next.resolve;
52
- setOptions(next.options || {});
53
- setOpenState(true);
54
- }, [setOpenState]);
52
+ // Defer the React state writes to a microtask so they run after any
53
+ // parent component's useInsertionEffect commit phase (Radix Dialog,
54
+ // CSS-in-JS layer injection, etc.). React 18/19 rejects setState
55
+ // scheduled during the insertion-effect phase with the warning
56
+ // "useInsertionEffect must not schedule updates" — see #1810.
57
+ queueMicrotask(() => {
58
+ setOptions(next.options || {});
59
+ setOpen(true);
60
+ });
61
+ }, []);
55
62
  const finalizeInteraction = React.useCallback(() => {
56
- setOpenState(false);
57
- processQueue();
58
- }, [processQueue, setOpenState]);
63
+ // Reset openRef BEFORE scheduling queue work so a subsequent confirm()
64
+ // call from the parent's onOpenChange propagation isn't dropped by the
65
+ // openRef.current === true guard (#1804). The visible dialog still
66
+ // closes via setOpen(false); only the internal lock flips early.
67
+ openRef.current = false;
68
+ setOpen(false);
69
+ // Defer queue advancement to the next microtask so the parent's
70
+ // onOpenChange / Promise resolution has a chance to propagate before
71
+ // a queued request reopens the dialog with new options.
72
+ queueMicrotask(() => {
73
+ processQueue();
74
+ });
75
+ }, [processQueue]);
59
76
 
60
77
  const confirm = React.useCallback(
61
78
  (newOptions?: ConfirmDialogOptions): Promise<boolean> => {
@@ -70,19 +87,27 @@ export function useConfirmDialog(): UseConfirmDialogReturn {
70
87
  );
71
88
  }
72
89
 
73
- // If dialog is already open, queue this request
90
+ // If dialog is already open or a previous interaction is still
91
+ // resolving, enqueue this request. processQueue() picks it up after
92
+ // the in-flight interaction finalises.
74
93
  if (openRef.current || resolveRef.current) {
75
94
  queueRef.current.push({ options: newOptions, resolve });
76
95
  return;
77
96
  }
78
97
 
79
- // Otherwise, show the dialog immediately
98
+ // Otherwise, claim the open slot synchronously and defer the
99
+ // actual setState writes to a microtask (same rationale as
100
+ // processQueue — keeps us out of the parent's insertion-effect
101
+ // commit phase).
102
+ openRef.current = true;
80
103
  resolveRef.current = resolve;
81
- setOptions(newOptions || {});
82
- setOpenState(true);
104
+ queueMicrotask(() => {
105
+ setOptions(newOptions || {});
106
+ setOpen(true);
107
+ });
83
108
  });
84
109
  },
85
- [setOpenState]
110
+ []
86
111
  );
87
112
 
88
113
  const handleConfirm = React.useCallback(async () => {