@open-mercato/ui 0.6.1-develop.3069.d40b4417a9 → 0.6.1-develop.3090.06ab462170
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/AppShell.js +52 -5
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/confirm-dialog/useConfirmDialog.js +19 -13
- package/dist/backend/confirm-dialog/useConfirmDialog.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +12 -2
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/AppShell.tsx +73 -10
- package/src/backend/confirm-dialog/__tests__/useConfirmDialog.test.tsx +250 -0
- package/src/backend/confirm-dialog/useConfirmDialog.tsx +41 -16
- package/src/backend/inputs/PhoneNumberField.tsx +16 -3
- package/src/backend/inputs/__tests__/PhoneNumberField.test.tsx +4 -2
|
@@ -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
|
|
5
|
-
"mappings": ";
|
|
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": "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",
|
|
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
|
|
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
|
-
|
|
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
|
|
5
|
-
"mappings": ";
|
|
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.
|
|
3
|
+
"version": "0.6.1-develop.3090.06ab462170",
|
|
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.
|
|
143
|
+
"@open-mercato/shared": "0.6.1-develop.3090.06ab462170",
|
|
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.
|
|
148
|
+
"@open-mercato/shared": "0.6.1-develop.3090.06ab462170",
|
|
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",
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
{/*
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
-
<
|
|
1143
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
82
|
-
|
|
104
|
+
queueMicrotask(() => {
|
|
105
|
+
setOptions(newOptions || {});
|
|
106
|
+
setOpen(true);
|
|
107
|
+
});
|
|
83
108
|
});
|
|
84
109
|
},
|
|
85
|
-
[
|
|
110
|
+
[]
|
|
86
111
|
);
|
|
87
112
|
|
|
88
113
|
const handleConfirm = React.useCallback(async () => {
|