@open-mercato/ui 0.9.1-develop.7476.1.342138d35e → 0.9.1-develop.7496.1.3346415f94
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/detail/InlineEditors.js +17 -6
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/forms/ActionsDropdown.js +52 -2
- package/dist/backend/forms/ActionsDropdown.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/__tests__/ActionsDropdown.test.tsx +105 -0
- package/src/backend/__tests__/next-dynamic-specifier.test.ts +23 -0
- package/src/backend/detail/InlineEditors.tsx +28 -6
- package/src/backend/detail/__tests__/InlineEditors.test.tsx +121 -6
- package/src/backend/forms/ActionsDropdown.tsx +71 -2
|
@@ -20,6 +20,7 @@ import { mapCrudServerErrorToFormErrors } from "../utils/serverErrors.js";
|
|
|
20
20
|
import { MarkdownPreview } from "../markdown/index.js";
|
|
21
21
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
22
22
|
const logger = createLogger("ui");
|
|
23
|
+
const INLINE_TRIGGER_HIDDEN_CLASSES = "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100";
|
|
23
24
|
function resolveInlineErrorMessage(err, fallbackMessage, translate) {
|
|
24
25
|
const raw = resolveRawInlineErrorMessage(err, fallbackMessage);
|
|
25
26
|
return translate(raw, raw);
|
|
@@ -112,11 +113,12 @@ function InlineTextEditor({
|
|
|
112
113
|
);
|
|
113
114
|
const triggerClasses = cn(
|
|
114
115
|
"shrink-0 transition-opacity duration-150",
|
|
115
|
-
editing ? "opacity-100" :
|
|
116
|
+
editing ? "opacity-100" : INLINE_TRIGGER_HIDDEN_CLASSES,
|
|
116
117
|
variant === "muted" ? "h-8 w-8" : null,
|
|
117
118
|
triggerClassName ?? null
|
|
118
119
|
);
|
|
119
120
|
const triggerSize = variant === "plain" ? "icon" : "sm";
|
|
121
|
+
const triggerLabel = editing ? t("ui.detail.inline.cancel", "Cancel") : t("ui.detail.inline.edit", "Edit");
|
|
120
122
|
const setEditingSafe = React.useCallback(
|
|
121
123
|
(next) => {
|
|
122
124
|
setEditing(next);
|
|
@@ -341,7 +343,9 @@ function InlineTextEditor({
|
|
|
341
343
|
const next = !editing;
|
|
342
344
|
setEditingSafe(next);
|
|
343
345
|
},
|
|
344
|
-
|
|
346
|
+
"aria-label": triggerLabel,
|
|
347
|
+
title: triggerLabel,
|
|
348
|
+
children: editing ? /* @__PURE__ */ jsx(X, { className: "h-4 w-4", "aria-hidden": true }) : /* @__PURE__ */ jsx(Pencil, { className: "h-4 w-4", "aria-hidden": true })
|
|
345
349
|
}
|
|
346
350
|
)
|
|
347
351
|
] }) });
|
|
@@ -465,9 +469,10 @@ function InlineMultilineEditor({
|
|
|
465
469
|
);
|
|
466
470
|
const triggerClasses = cn(
|
|
467
471
|
"transition-opacity duration-150",
|
|
468
|
-
editing ? "opacity-100" :
|
|
472
|
+
editing ? "opacity-100" : INLINE_TRIGGER_HIDDEN_CLASSES,
|
|
469
473
|
triggerClassName ?? null
|
|
470
474
|
);
|
|
475
|
+
const triggerLabel = editing ? t("ui.detail.inline.cancel", "Cancel") : t("ui.detail.inline.edit", "Edit");
|
|
471
476
|
const handleSave = React.useCallback(async () => {
|
|
472
477
|
const trimmed = draft.trim();
|
|
473
478
|
const validationError = adjustError(draft);
|
|
@@ -591,7 +596,9 @@ function InlineMultilineEditor({
|
|
|
591
596
|
event.stopPropagation();
|
|
592
597
|
setEditing((state) => !state);
|
|
593
598
|
},
|
|
594
|
-
|
|
599
|
+
"aria-label": triggerLabel,
|
|
600
|
+
title: triggerLabel,
|
|
601
|
+
children: editing ? /* @__PURE__ */ jsx(X, { className: "h-4 w-4", "aria-hidden": true }) : /* @__PURE__ */ jsx(Pencil, { className: "h-4 w-4", "aria-hidden": true })
|
|
595
602
|
}
|
|
596
603
|
)
|
|
597
604
|
] }) });
|
|
@@ -625,10 +632,11 @@ function InlineSelectEditor({
|
|
|
625
632
|
);
|
|
626
633
|
const triggerClasses = cn(
|
|
627
634
|
"shrink-0 transition-opacity duration-150",
|
|
628
|
-
editing ? "opacity-100" :
|
|
635
|
+
editing ? "opacity-100" : INLINE_TRIGGER_HIDDEN_CLASSES,
|
|
629
636
|
variant === "muted" ? "h-8 w-8" : null,
|
|
630
637
|
triggerClassName ?? null
|
|
631
638
|
);
|
|
639
|
+
const triggerLabel = editing ? t("ui.detail.inline.cancel", "Cancel") : t("ui.detail.inline.edit", "Edit");
|
|
632
640
|
const handleSave = React.useCallback(async () => {
|
|
633
641
|
setSaving(true);
|
|
634
642
|
try {
|
|
@@ -691,12 +699,15 @@ function InlineSelectEditor({
|
|
|
691
699
|
event.stopPropagation();
|
|
692
700
|
setEditing((state) => !state);
|
|
693
701
|
},
|
|
694
|
-
|
|
702
|
+
"aria-label": triggerLabel,
|
|
703
|
+
title: triggerLabel,
|
|
704
|
+
children: editing ? /* @__PURE__ */ jsx(X, { className: "h-4 w-4", "aria-hidden": true }) : /* @__PURE__ */ jsx(Pencil, { className: "h-4 w-4", "aria-hidden": true })
|
|
695
705
|
}
|
|
696
706
|
)
|
|
697
707
|
] }) });
|
|
698
708
|
}
|
|
699
709
|
export {
|
|
710
|
+
INLINE_TRIGGER_HIDDEN_CLASSES,
|
|
700
711
|
InlineMultilineEditor,
|
|
701
712
|
InlineSelectEditor,
|
|
702
713
|
InlineTextEditor,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/detail/InlineEditors.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { FileCode, Loader2, Mail, Pencil, Phone, X } from 'lucide-react'\nimport type { PluggableList } from 'unified'\nimport { PhoneNumberField } from '@open-mercato/ui/backend/inputs/PhoneNumberField'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { DatePicker } from '@open-mercato/ui/primitives/date-picker'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { format } from 'date-fns/format'\nimport { LoadingMessage } from './LoadingMessage'\nimport { mapCrudServerErrorToFormErrors } from '../utils/serverErrors'\nimport { MarkdownPreview } from '../markdown'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui')\n\n// Server validation errors arrive as stable i18n keys (e.g. `customers.people.form.*`), the\n// same contract CrudForm resolves via translateValidationMessage; without this they render\n// raw. `t(msg, msg)` is a no-op for already-localized strings.\nfunction resolveInlineErrorMessage(\n err: unknown,\n fallbackMessage: string,\n translate: (key: string, fallback: string) => string,\n): string {\n const raw = resolveRawInlineErrorMessage(err, fallbackMessage)\n return translate(raw, raw)\n}\n\nfunction resolveRawInlineErrorMessage(err: unknown, fallbackMessage: string): string {\n const { message, fieldErrors } = mapCrudServerErrorToFormErrors(err)\n const firstFieldError = fieldErrors\n ? Object.values(fieldErrors).find((text) => typeof text === 'string' && text.trim().length)\n : null\n if (typeof firstFieldError === 'string' && firstFieldError.trim().length) {\n return firstFieldError.trim()\n }\n if (typeof message === 'string' && message.trim().length) {\n return message.trim()\n }\n if (err instanceof Error && typeof err.message === 'string' && err.message.trim().length) {\n return err.message.trim()\n }\n if (typeof err === 'string' && err.trim().length) {\n return err.trim()\n }\n return fallbackMessage\n}\n\ntype EditorVariant = 'default' | 'muted' | 'plain'\n\nexport type InlineFieldType = 'text' | 'email' | 'tel' | 'url'\n\nconst ALLOWED_INLINE_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:'])\n\nexport function resolveSafeInlineUrlHref(value: string): string | null {\n const trimmed = value.trim()\n if (!trimmed.length) return null\n try {\n const parsed = new URL(trimmed)\n return ALLOWED_INLINE_URL_PROTOCOLS.has(parsed.protocol) ? trimmed : null\n } catch {\n return null\n }\n}\n\nexport type InlineTextEditorProps = {\n label: string\n value: string | null | undefined\n placeholder?: string\n emptyLabel: string\n onSave: (value: string | null) => Promise<void>\n type?: InlineFieldType\n inputType?: React.HTMLInputTypeAttribute\n validator?: (value: string) => string | null\n variant?: EditorVariant\n activateOnClick?: boolean\n containerClassName?: string\n triggerClassName?: string\n hideLabel?: boolean\n renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string; type: InlineFieldType }) => React.ReactNode\n onEditingChange?: (editing: boolean) => void\n renderActions?: React.ReactNode\n saveLabel?: string\n recordId?: string\n onDraftChange?: (draft: string) => void\n renderBelowInput?: (params: {\n draft: string\n resolvedType: InlineFieldType\n error: string | null\n saving: boolean\n }) => React.ReactNode\n}\n\nexport function InlineTextEditor({\n label,\n value,\n placeholder,\n emptyLabel,\n onSave,\n type = 'text',\n inputType,\n validator,\n variant = 'default',\n activateOnClick = false,\n containerClassName,\n triggerClassName,\n hideLabel = false,\n renderDisplay,\n onEditingChange,\n renderActions,\n saveLabel,\n onDraftChange,\n renderBelowInput,\n}: InlineTextEditorProps) {\n const t = useT()\n const [editing, setEditing] = React.useState(false)\n const [draft, setDraft] = React.useState(value ?? '')\n const [error, setError] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const computedSaveLabel = saveLabel ?? t('ui.detail.inline.saveShortcut', 'Save \u2318\u23CE / Ctrl+Enter')\n const fallbackError = React.useMemo(\n () => t('ui.detail.inline.error', 'Failed to save value.'),\n [t],\n )\n const resolvedType = React.useMemo<InlineFieldType>(() => {\n if (type && typeof type === 'string') return type\n if (inputType && typeof inputType === 'string') {\n const normalized = inputType.toLowerCase()\n if (normalized === 'email' || normalized === 'tel' || normalized === 'url') {\n return normalized as InlineFieldType\n }\n }\n return 'text'\n }, [inputType, type])\n\n React.useEffect(() => {\n if (!editing) setDraft(value ?? '')\n }, [editing, value])\n\n React.useEffect(() => {\n if (onDraftChange) onDraftChange(draft)\n }, [draft, onDraftChange])\n\n const containerClasses = cn(\n 'group overflow-hidden',\n variant === 'muted'\n ? 'relative rounded border bg-muted/30 p-3'\n : variant === 'plain'\n ? 'relative flex items-center gap-3 rounded-none border-0 p-0'\n : 'rounded-lg border p-4',\n activateOnClick && !editing ? 'cursor-pointer' : null,\n containerClassName ?? null,\n )\n const readOnlyWrapperClasses = cn(\n 'flex-1 min-w-0',\n activateOnClick && !editing ? 'cursor-pointer' : null,\n variant === 'plain' ? 'flex items-center gap-2' : null,\n )\n const triggerClasses = cn(\n 'shrink-0 transition-opacity duration-150',\n editing\n ? 'opacity-100'\n : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',\n variant === 'muted' ? 'h-8 w-8' : null,\n triggerClassName ?? null,\n )\n const triggerSize = variant === 'plain' ? 'icon' : 'sm'\n\n const setEditingSafe = React.useCallback(\n (next: boolean) => {\n setEditing(next)\n if (onEditingChange) onEditingChange(next)\n },\n [onEditingChange],\n )\n\n const handleActivate = React.useCallback(() => {\n if (!editing) setEditingSafe(true)\n }, [editing, setEditingSafe])\n\n const handleInteractiveClick = React.useCallback(\n (event: React.MouseEvent<HTMLDivElement>) => {\n if (!activateOnClick || editing) return\n const target = event.target as HTMLElement\n const interactiveElement = target.closest('button, input, select, textarea, a, [role=\"link\"]')\n if (interactiveElement) {\n if (interactiveElement.tagName.toLowerCase() === 'a') {\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {\n return\n }\n event.preventDefault()\n // let the link click toggle edit mode instead of navigating away\n } else {\n return\n }\n }\n handleActivate()\n },\n [activateOnClick, editing, handleActivate],\n )\n\n const handleContainerKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (!activateOnClick || editing) return\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n handleActivate()\n }\n },\n [activateOnClick, editing, handleActivate],\n )\n\n const handleSave = React.useCallback(async () => {\n const trimmed = draft.trim()\n if (validator) {\n const validationError = validator(trimmed)\n if (validationError) {\n setError(validationError)\n return\n }\n }\n setError(null)\n setSaving(true)\n try {\n await onSave(trimmed.length ? trimmed : null)\n setEditingSafe(false)\n } catch (err) {\n setError(resolveInlineErrorMessage(err, fallbackError, t))\n } finally {\n setSaving(false)\n }\n }, [draft, fallbackError, onSave, setEditingSafe, t, validator])\n\n const interactiveProps: React.HTMLAttributes<HTMLDivElement> =\n activateOnClick && !editing\n ? {\n role: 'button' as const,\n tabIndex: 0,\n onKeyDown: handleContainerKeyDown,\n }\n : {}\n\n const displayContent = React.useMemo(() => {\n if (renderDisplay) {\n return renderDisplay({ value, emptyLabel, type: resolvedType })\n }\n const baseValue = value && typeof value === 'string' ? value : ''\n const anchorClass =\n variant === 'plain'\n ? 'inline-flex max-w-full min-w-0 items-center gap-2 text-xl font-semibold leading-tight text-primary hover:text-primary/90 hover:underline'\n : 'flex max-w-full min-w-0 items-center gap-2 text-sm text-primary hover:text-primary/90 hover:underline'\n const textClass = variant === 'plain' ? 'text-2xl font-semibold leading-tight' : 'text-sm break-words'\n if (resolvedType === 'email') {\n if (!baseValue.length) {\n return (\n <p className={variant === 'plain' ? 'text-base text-muted-foreground' : 'text-sm text-muted-foreground'}>\n {emptyLabel}\n </p>\n )\n }\n return (\n <a className={anchorClass} href={`mailto:${baseValue}`}>\n <Mail aria-hidden className={variant === 'plain' ? 'h-5 w-5' : 'h-4 w-4'} />\n <span className=\"truncate min-w-0\">{baseValue}</span>\n </a>\n )\n }\n if (!baseValue.length) {\n return (\n <p className={variant === 'plain' ? 'text-base text-muted-foreground' : 'text-sm text-muted-foreground'}>\n {emptyLabel}\n </p>\n )\n }\n if (resolvedType === 'tel') {\n const sanitizedValue = baseValue.replace(/[^+\\d]/g, '')\n const hrefValue = sanitizedValue.length ? sanitizedValue : baseValue\n return (\n <a className={anchorClass} href={`tel:${hrefValue}`}>\n <Phone aria-hidden className={variant === 'plain' ? 'h-5 w-5' : 'h-4 w-4'} />\n <span className=\"truncate\">{baseValue}</span>\n </a>\n )\n }\n if (resolvedType === 'url') {\n const safeHref = resolveSafeInlineUrlHref(baseValue)\n if (!safeHref) {\n return <p className={textClass}>{baseValue}</p>\n }\n return (\n <a className={textClass} href={safeHref} target=\"_blank\" rel=\"noopener noreferrer\">\n {baseValue}\n </a>\n )\n }\n return <p className={textClass}>{baseValue}</p>\n }, [emptyLabel, renderDisplay, resolvedType, value, variant])\n\n const editingContainerClass = variant === 'plain' ? 'mt-0 w-full max-w-sm space-y-3' : 'mt-2 space-y-3'\n\n return (\n <div className={containerClasses} onClick={handleInteractiveClick}>\n <div className=\"flex items-start justify-between gap-2 min-w-0\">\n <div className={readOnlyWrapperClasses} {...interactiveProps}>\n {hideLabel ? null : <p className=\"text-xs uppercase tracking-wide text-muted-foreground\">{label}</p>}\n {editing ? (\n <form\n className={editingContainerClass}\n onSubmit={(event) => {\n event.preventDefault()\n if (!saving) void handleSave()\n }}\n onKeyDown={(event) => {\n if (event.key === 'Escape') {\n event.preventDefault()\n setEditingSafe(false)\n setError(null)\n return\n }\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n if (!saving) void handleSave()\n }\n }}\n >\n {(() => {\n const lowerType = (inputType ?? resolvedType ?? '').toLowerCase()\n const isDate = lowerType === 'date'\n const isDateTime = lowerType === 'datetime-local' || lowerType === 'datetime'\n if (resolvedType === 'tel') {\n return (\n <PhoneNumberField\n value={draft.length ? draft : undefined}\n onValueChange={(next) => {\n if (error) setError(null)\n setDraft(next ?? '')\n }}\n placeholder={placeholder}\n autoFocus\n disabled={saving}\n minDigits={7}\n />\n )\n }\n if (isDate || isDateTime) {\n let parsed: Date | null = null\n if (draft && draft.length) {\n const candidate = new Date(draft)\n if (!Number.isNaN(candidate.getTime())) parsed = candidate\n }\n return (\n <DatePicker\n value={parsed}\n onChange={(date) => {\n if (error) setError(null)\n const formatted = !date\n ? ''\n : isDateTime\n ? format(date, \"yyyy-MM-dd'T'HH:mm\")\n : format(date, 'yyyy-MM-dd')\n setDraft(formatted)\n setSaving(true)\n ;(async () => {\n try {\n await onSave(formatted.length ? formatted : null)\n setEditingSafe(false)\n } catch (err) {\n setError(resolveInlineErrorMessage(err, fallbackError, t))\n } finally {\n setSaving(false)\n }\n })()\n }}\n withTime={isDateTime}\n footer=\"apply-cancel\"\n placeholder={placeholder}\n disabled={saving}\n />\n )\n }\n return (\n <input\n className=\"w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n value={draft}\n onChange={(event) => {\n if (error) setError(null)\n setDraft(event.target.value)\n }}\n placeholder={placeholder}\n type={inputType ?? resolvedType}\n autoFocus\n />\n )\n })()}\n {error ? <p className=\"text-xs text-destructive\">{error}</p> : null}\n {renderBelowInput ? renderBelowInput({ draft, resolvedType, error, saving }) : null}\n {(() => {\n const lowerType = (inputType ?? resolvedType ?? '').toLowerCase()\n const isDateLike = lowerType === 'date' || lowerType === 'datetime-local' || lowerType === 'datetime'\n if (isDateLike) return null\n return (\n <div className=\"flex items-center gap-2\">\n <Button type=\"submit\" size=\"sm\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 h-3.5 w-3.5 animate-spin\" /> : null}\n {computedSaveLabel}\n </Button>\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => setEditingSafe(false)} disabled={saving}>\n {t('ui.detail.inline.cancel', 'Cancel')}\n </Button>\n </div>\n )\n })()}\n </form>\n ) : (\n <div className={variant === 'plain' ? '' : 'mt-1'}>{displayContent}</div>\n )}\n </div>\n {renderActions ? <div className=\"flex items-center gap-2\">{renderActions}</div> : null}\n <Button\n type=\"button\"\n variant=\"ghost\"\n size={triggerSize}\n className={triggerClasses}\n onClick={(event) => {\n event.stopPropagation()\n const next = !editing\n setEditingSafe(next)\n }}\n >\n {editing ? <X className=\"h-4 w-4\" /> : <Pencil className=\"h-4 w-4\" />}\n </Button>\n </div>\n </div>\n )\n}\n\nexport type InlineMultilineEditorProps = {\n label: string\n value: string | null | undefined\n placeholder?: string\n emptyLabel: string\n onSave: (value: string | null) => Promise<void>\n validator?: (value: string) => string | null\n variant?: EditorVariant\n activateOnClick?: boolean\n containerClassName?: string\n triggerClassName?: string\n renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string }) => React.ReactNode\n}\n\nconst isTestEnv =\n typeof process !== 'undefined' &&\n (process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')\n\nlet markdownPluginsPromise: Promise<PluggableList> | null = null\n\nasync function loadMarkdownPlugins(): Promise<PluggableList> {\n if (isTestEnv) return []\n if (!markdownPluginsPromise) {\n markdownPluginsPromise = import('remark-gfm')\n .then((mod) => [mod.default ?? mod] as PluggableList)\n .catch(() => [])\n }\n return markdownPluginsPromise\n}\n\nexport function InlineMultilineEditor({\n label,\n value,\n placeholder,\n emptyLabel,\n onSave,\n validator,\n variant = 'default',\n activateOnClick = true,\n containerClassName,\n triggerClassName,\n renderDisplay,\n}: InlineMultilineEditorProps) {\n const t = useT()\n const [editing, setEditing] = React.useState(false)\n const [draft, setDraft] = React.useState(value ?? '')\n const [error, setError] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const [isMarkdownEnabled, setIsMarkdownEnabled] = React.useState(true)\n const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)\n const [markdownPlugins, setMarkdownPlugins] = React.useState<PluggableList>([])\n const fallbackError = React.useMemo(\n () => t('ui.detail.inline.error', 'Failed to save value.'),\n [t],\n )\n React.useEffect(() => {\n if (isTestEnv) return\n let mounted = true\n void loadMarkdownPlugins().then((plugins) => {\n if (!mounted) return\n setMarkdownPlugins(plugins)\n })\n return () => {\n mounted = false\n }\n }, [])\n\n const adjustTextareaSize = React.useCallback((element: HTMLTextAreaElement | null) => {\n if (!element) return\n element.style.height = 'auto'\n element.style.height = `${element.scrollHeight}px`\n }, [])\n\n React.useEffect(() => {\n adjustTextareaSize(textareaRef.current)\n }, [adjustTextareaSize, draft, isMarkdownEnabled])\n\n React.useEffect(() => {\n if (!editing) return\n const element = textareaRef.current\n if (!element) return\n window.requestAnimationFrame(() => {\n adjustTextareaSize(element)\n element.focus()\n })\n }, [adjustTextareaSize, editing, isMarkdownEnabled])\n\n const handleMarkdownToggle = React.useCallback(() => {\n setIsMarkdownEnabled((prev) => !prev)\n }, [])\n\n React.useEffect(() => {\n if (!editing) {\n setDraft(value ?? '')\n setError(null)\n }\n }, [editing, value])\n\n const handleActivate = React.useCallback(() => {\n if (!editing) setEditing(true)\n }, [editing])\n\n const handleInteractiveClick = React.useCallback(\n (event: React.MouseEvent<HTMLDivElement>) => {\n if (!activateOnClick || editing) return\n const target = event.target as HTMLElement\n const interactiveElement = target.closest('button, input, select, textarea, a, [role=\"link\"]')\n if (interactiveElement) {\n if (interactiveElement.tagName.toLowerCase() === 'a') {\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {\n return\n }\n event.preventDefault()\n // Links should not block activation; let the click toggle edit mode\n } else {\n return\n }\n }\n handleActivate()\n },\n [activateOnClick, editing, handleActivate],\n )\n\n const handleContainerKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (!activateOnClick || editing) return\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n handleActivate()\n }\n },\n [activateOnClick, editing, handleActivate],\n )\n\n const adjustError = React.useCallback(\n (nextValue: string) => {\n if (!validator) return null\n const trimmed = nextValue.trim()\n return validator(trimmed)\n },\n [validator],\n )\n\n const containerClasses = cn(\n 'group rounded-lg border p-4',\n variant === 'muted' ? 'bg-muted/30' : null,\n activateOnClick && !editing ? 'cursor-pointer' : null,\n containerClassName ?? null,\n )\n const triggerClasses = cn(\n 'transition-opacity duration-150',\n editing\n ? 'opacity-100'\n : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',\n triggerClassName ?? null,\n )\n\n const handleSave = React.useCallback(async () => {\n const trimmed = draft.trim()\n const validationError = adjustError(draft)\n if (validationError) {\n setError(validationError)\n return\n }\n setSaving(true)\n try {\n await onSave(trimmed.length ? trimmed : null)\n setEditing(false)\n } catch (err) {\n setError(resolveInlineErrorMessage(err, fallbackError, t))\n } finally {\n setSaving(false)\n }\n }, [adjustError, draft, fallbackError, onSave, t])\n\n return (\n <div className={containerClasses} onClick={handleInteractiveClick}>\n <div className=\"flex items-start justify-between gap-2\">\n <div\n className={cn('flex-1 min-w-0', activateOnClick && !editing ? 'cursor-pointer' : null)}\n {...(activateOnClick && !editing\n ? { role: 'button' as const, tabIndex: 0, onKeyDown: handleContainerKeyDown }\n : {})}\n >\n <p className=\"text-xs uppercase tracking-wide text-muted-foreground\">{label}</p>\n {editing ? (\n <form\n className=\"mt-2 space-y-3\"\n onSubmit={(event) => {\n event.preventDefault()\n if (!saving) void handleSave()\n }}\n onKeyDown={(event) => {\n if (event.key === 'Escape') {\n event.preventDefault()\n setEditing(false)\n setError(null)\n return\n }\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n if (!saving) void handleSave()\n }\n }}\n >\n <Textarea\n ref={textareaRef}\n rows={isMarkdownEnabled ? 8 : 3}\n className=\"w-full resize-none overflow-hidden rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n placeholder={placeholder}\n value={draft}\n onChange={(event) => {\n if (error) setError(null)\n setDraft(event.target.value)\n }}\n onInput={(event) => adjustTextareaSize(event.currentTarget)}\n autoFocus\n disabled={saving}\n />\n {error ? <p className=\"text-xs text-destructive\">{error}</p> : null}\n <div className=\"flex items-center gap-2\">\n <Button type=\"submit\" size=\"sm\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 h-3.5 w-3.5 animate-spin\" /> : null}\n {t('ui.detail.inline.saveShortcut', 'Save \u2318\u23CE / Ctrl+Enter')}\n </Button>\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => setEditing(false)} disabled={saving}>\n {t('ui.detail.inline.cancel', 'Cancel')}\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n onClick={handleMarkdownToggle}\n aria-pressed={isMarkdownEnabled}\n title={\n isMarkdownEnabled\n ? t('ui.detail.inline.markdownDisable', 'Disable Markdown')\n : t('ui.detail.inline.markdownEnable', 'Enable Markdown')\n }\n aria-label={\n isMarkdownEnabled\n ? t('ui.detail.inline.markdownDisable', 'Disable Markdown')\n : t('ui.detail.inline.markdownEnable', 'Enable Markdown')\n }\n className={cn('h-8 w-8', isMarkdownEnabled ? 'text-primary' : undefined)}\n disabled={saving}\n >\n <FileCode className=\"h-4 w-4\" aria-hidden />\n <span className=\"sr-only\">\n {isMarkdownEnabled\n ? t('ui.detail.inline.markdownDisable', 'Disable Markdown')\n : t('ui.detail.inline.markdownEnable', 'Enable Markdown')}\n </span>\n </Button>\n </div>\n </form>\n ) : (\n <div\n className={cn(\n 'mt-1 text-sm break-words',\n renderDisplay ? null : 'whitespace-pre-wrap',\n activateOnClick && !editing ? 'cursor-pointer' : null,\n )}\n >\n {renderDisplay ? (\n renderDisplay({ value, emptyLabel })\n ) : value && value.length ? (\n <MarkdownPreview\n remarkPlugins={markdownPlugins}\n className=\"prose prose-sm max-w-none text-foreground [&>*]:my-2 [&>*:last-child]:mb-0 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5\"\n >\n {value}\n </MarkdownPreview>\n ) : (\n <span className=\"text-muted-foreground\">{emptyLabel}</span>\n )}\n </div>\n )}\n </div>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className={triggerClasses}\n onClick={(event) => {\n event.stopPropagation()\n setEditing((state) => !state)\n }}\n >\n {editing ? <X className=\"h-4 w-4\" /> : <Pencil className=\"h-4 w-4\" />}\n </Button>\n </div>\n </div>\n )\n}\n\nexport type InlineSelectOption = { value: string; label: string; description?: string }\n\nexport type InlineSelectEditorProps = {\n label: string\n value: string | null | undefined\n emptyLabel: string\n options: InlineSelectOption[]\n onSave: (value: string | null) => Promise<void>\n variant?: EditorVariant\n activateOnClick?: boolean\n containerClassName?: string\n triggerClassName?: string\n hideLabel?: boolean\n renderEditor?: (params: { value: string; onChange: (next: string) => void }) => React.ReactNode\n renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string }) => React.ReactNode\n}\n\nexport function InlineSelectEditor({\n label,\n value,\n emptyLabel,\n options,\n onSave,\n variant = 'default',\n activateOnClick = false,\n containerClassName,\n triggerClassName,\n hideLabel = false,\n renderEditor,\n renderDisplay,\n}: InlineSelectEditorProps) {\n const t = useT()\n const [editing, setEditing] = React.useState(false)\n const [draft, setDraft] = React.useState<string>(value ?? '')\n const [saving, setSaving] = React.useState(false)\n\n React.useEffect(() => {\n if (!editing) setDraft(value ?? '')\n }, [editing, value])\n\n const containerClasses = cn(\n 'group',\n variant === 'muted'\n ? 'relative rounded border bg-muted/30 p-3'\n : variant === 'plain'\n ? 'relative flex flex-col gap-1 rounded-none border-0 p-0'\n : 'rounded-lg border bg-card p-4',\n activateOnClick && !editing ? 'cursor-pointer' : null,\n containerClassName ?? null,\n )\n const triggerClasses = cn(\n 'shrink-0 transition-opacity duration-150',\n editing\n ? 'opacity-100'\n : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100',\n variant === 'muted' ? 'h-8 w-8' : null,\n triggerClassName ?? null,\n )\n\n const handleSave = React.useCallback(async () => {\n setSaving(true)\n try {\n await onSave(draft.length ? draft : null)\n setEditing(false)\n } catch (err) {\n const message = err instanceof Error ? err.message : t('ui.detail.inline.error', 'Failed to save value.')\n logger.error(message, { err })\n } finally {\n setSaving(false)\n }\n }, [draft, onSave, t])\n\n const selected = options.find((option) => option.value === value)\n\n const interactiveProps: React.HTMLAttributes<HTMLDivElement> =\n activateOnClick && !editing\n ? {\n role: 'button' as const,\n tabIndex: 0,\n onClick: () => setEditing(true),\n onKeyDown: (event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n setEditing(true)\n }\n },\n }\n : {}\n\n return (\n <div className={containerClasses}>\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex-1 min-w-0\" {...interactiveProps}>\n {hideLabel ? null : <p className=\"text-xs uppercase tracking-wide text-muted-foreground\">{label}</p>}\n {editing ? (\n <div className={variant === 'plain' ? 'space-y-2 pt-1' : 'mt-2 space-y-2'}>\n {renderEditor ? (\n renderEditor({ value: draft, onChange: setDraft })\n ) : (\n <Select\n value={draft || undefined}\n onValueChange={(next) => setDraft(next ?? '')}\n >\n <SelectTrigger>\n <SelectValue placeholder={t('ui.detail.inline.select.placeholder', 'Not set')} />\n </SelectTrigger>\n <SelectContent>\n {options.map((option) => (\n <SelectItem key={option.value} value={option.value}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n <div className=\"flex items-center gap-2\">\n <Button type=\"button\" size=\"sm\" onClick={() => void handleSave()} disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 h-3.5 w-3.5 animate-spin\" /> : null}\n {t('ui.detail.inline.saveShortcut', 'Save \u2318\u23CE / Ctrl+Enter')}\n </Button>\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => setEditing(false)} disabled={saving}>\n {t('ui.detail.inline.cancel', 'Cancel')}\n </Button>\n </div>\n </div>\n ) : (\n <div className={variant === 'plain' ? 'flex items-center gap-2' : 'mt-1 text-sm'}>\n {renderDisplay ? (\n renderDisplay({ value, emptyLabel })\n ) : selected ? (\n <div className=\"space-y-0.5\">\n <p className=\"font-medium leading-tight\">{selected.label}</p>\n {selected.description ? (\n <p className=\"text-xs text-muted-foreground\">{selected.description}</p>\n ) : null}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">{emptyLabel}</span>\n )}\n </div>\n )}\n </div>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size={variant === 'plain' ? 'icon' : 'sm'}\n className={triggerClasses}\n onClick={(event) => {\n event.stopPropagation()\n setEditing((state) => !state)\n }}\n >\n {editing ? <X className=\"h-4 w-4\" /> : <Pencil className=\"h-4 w-4\" />}\n </Button>\n </div>\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { FileCode, Loader2, Mail, Pencil, Phone, X } from 'lucide-react'\nimport type { PluggableList } from 'unified'\nimport { PhoneNumberField } from '@open-mercato/ui/backend/inputs/PhoneNumberField'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\nimport { Textarea } from '@open-mercato/ui/primitives/textarea'\nimport { DatePicker } from '@open-mercato/ui/primitives/date-picker'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { format } from 'date-fns/format'\nimport { LoadingMessage } from './LoadingMessage'\nimport { mapCrudServerErrorToFormErrors } from '../utils/serverErrors'\nimport { MarkdownPreview } from '../markdown'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui')\n\n// Coarse-pointer devices never fire hover, so the hover-only reveal would leave the\n// edit trigger permanently at opacity-0 and untappable. Keep it visible there instead.\n// Exported so sibling inline editors outside this file share one definition rather than\n// re-deriving it \u2014 the drift that left these triggers hover-only in the first place.\nexport const INLINE_TRIGGER_HIDDEN_CLASSES =\n 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100'\n\n// Server validation errors arrive as stable i18n keys (e.g. `customers.people.form.*`), the\n// same contract CrudForm resolves via translateValidationMessage; without this they render\n// raw. `t(msg, msg)` is a no-op for already-localized strings.\nfunction resolveInlineErrorMessage(\n err: unknown,\n fallbackMessage: string,\n translate: (key: string, fallback: string) => string,\n): string {\n const raw = resolveRawInlineErrorMessage(err, fallbackMessage)\n return translate(raw, raw)\n}\n\nfunction resolveRawInlineErrorMessage(err: unknown, fallbackMessage: string): string {\n const { message, fieldErrors } = mapCrudServerErrorToFormErrors(err)\n const firstFieldError = fieldErrors\n ? Object.values(fieldErrors).find((text) => typeof text === 'string' && text.trim().length)\n : null\n if (typeof firstFieldError === 'string' && firstFieldError.trim().length) {\n return firstFieldError.trim()\n }\n if (typeof message === 'string' && message.trim().length) {\n return message.trim()\n }\n if (err instanceof Error && typeof err.message === 'string' && err.message.trim().length) {\n return err.message.trim()\n }\n if (typeof err === 'string' && err.trim().length) {\n return err.trim()\n }\n return fallbackMessage\n}\n\ntype EditorVariant = 'default' | 'muted' | 'plain'\n\nexport type InlineFieldType = 'text' | 'email' | 'tel' | 'url'\n\nconst ALLOWED_INLINE_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:'])\n\nexport function resolveSafeInlineUrlHref(value: string): string | null {\n const trimmed = value.trim()\n if (!trimmed.length) return null\n try {\n const parsed = new URL(trimmed)\n return ALLOWED_INLINE_URL_PROTOCOLS.has(parsed.protocol) ? trimmed : null\n } catch {\n return null\n }\n}\n\nexport type InlineTextEditorProps = {\n label: string\n value: string | null | undefined\n placeholder?: string\n emptyLabel: string\n onSave: (value: string | null) => Promise<void>\n type?: InlineFieldType\n inputType?: React.HTMLInputTypeAttribute\n validator?: (value: string) => string | null\n variant?: EditorVariant\n activateOnClick?: boolean\n containerClassName?: string\n triggerClassName?: string\n hideLabel?: boolean\n renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string; type: InlineFieldType }) => React.ReactNode\n onEditingChange?: (editing: boolean) => void\n renderActions?: React.ReactNode\n saveLabel?: string\n recordId?: string\n onDraftChange?: (draft: string) => void\n renderBelowInput?: (params: {\n draft: string\n resolvedType: InlineFieldType\n error: string | null\n saving: boolean\n }) => React.ReactNode\n}\n\nexport function InlineTextEditor({\n label,\n value,\n placeholder,\n emptyLabel,\n onSave,\n type = 'text',\n inputType,\n validator,\n variant = 'default',\n activateOnClick = false,\n containerClassName,\n triggerClassName,\n hideLabel = false,\n renderDisplay,\n onEditingChange,\n renderActions,\n saveLabel,\n onDraftChange,\n renderBelowInput,\n}: InlineTextEditorProps) {\n const t = useT()\n const [editing, setEditing] = React.useState(false)\n const [draft, setDraft] = React.useState(value ?? '')\n const [error, setError] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const computedSaveLabel = saveLabel ?? t('ui.detail.inline.saveShortcut', 'Save \u2318\u23CE / Ctrl+Enter')\n const fallbackError = React.useMemo(\n () => t('ui.detail.inline.error', 'Failed to save value.'),\n [t],\n )\n const resolvedType = React.useMemo<InlineFieldType>(() => {\n if (type && typeof type === 'string') return type\n if (inputType && typeof inputType === 'string') {\n const normalized = inputType.toLowerCase()\n if (normalized === 'email' || normalized === 'tel' || normalized === 'url') {\n return normalized as InlineFieldType\n }\n }\n return 'text'\n }, [inputType, type])\n\n React.useEffect(() => {\n if (!editing) setDraft(value ?? '')\n }, [editing, value])\n\n React.useEffect(() => {\n if (onDraftChange) onDraftChange(draft)\n }, [draft, onDraftChange])\n\n const containerClasses = cn(\n 'group overflow-hidden',\n variant === 'muted'\n ? 'relative rounded border bg-muted/30 p-3'\n : variant === 'plain'\n ? 'relative flex items-center gap-3 rounded-none border-0 p-0'\n : 'rounded-lg border p-4',\n activateOnClick && !editing ? 'cursor-pointer' : null,\n containerClassName ?? null,\n )\n const readOnlyWrapperClasses = cn(\n 'flex-1 min-w-0',\n activateOnClick && !editing ? 'cursor-pointer' : null,\n variant === 'plain' ? 'flex items-center gap-2' : null,\n )\n const triggerClasses = cn(\n 'shrink-0 transition-opacity duration-150',\n editing\n ? 'opacity-100'\n : INLINE_TRIGGER_HIDDEN_CLASSES,\n variant === 'muted' ? 'h-8 w-8' : null,\n triggerClassName ?? null,\n )\n const triggerSize = variant === 'plain' ? 'icon' : 'sm'\n const triggerLabel = editing\n ? t('ui.detail.inline.cancel', 'Cancel')\n : t('ui.detail.inline.edit', 'Edit')\n\n const setEditingSafe = React.useCallback(\n (next: boolean) => {\n setEditing(next)\n if (onEditingChange) onEditingChange(next)\n },\n [onEditingChange],\n )\n\n const handleActivate = React.useCallback(() => {\n if (!editing) setEditingSafe(true)\n }, [editing, setEditingSafe])\n\n const handleInteractiveClick = React.useCallback(\n (event: React.MouseEvent<HTMLDivElement>) => {\n if (!activateOnClick || editing) return\n const target = event.target as HTMLElement\n const interactiveElement = target.closest('button, input, select, textarea, a, [role=\"link\"]')\n if (interactiveElement) {\n if (interactiveElement.tagName.toLowerCase() === 'a') {\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {\n return\n }\n event.preventDefault()\n // let the link click toggle edit mode instead of navigating away\n } else {\n return\n }\n }\n handleActivate()\n },\n [activateOnClick, editing, handleActivate],\n )\n\n const handleContainerKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (!activateOnClick || editing) return\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n handleActivate()\n }\n },\n [activateOnClick, editing, handleActivate],\n )\n\n const handleSave = React.useCallback(async () => {\n const trimmed = draft.trim()\n if (validator) {\n const validationError = validator(trimmed)\n if (validationError) {\n setError(validationError)\n return\n }\n }\n setError(null)\n setSaving(true)\n try {\n await onSave(trimmed.length ? trimmed : null)\n setEditingSafe(false)\n } catch (err) {\n setError(resolveInlineErrorMessage(err, fallbackError, t))\n } finally {\n setSaving(false)\n }\n }, [draft, fallbackError, onSave, setEditingSafe, t, validator])\n\n const interactiveProps: React.HTMLAttributes<HTMLDivElement> =\n activateOnClick && !editing\n ? {\n role: 'button' as const,\n tabIndex: 0,\n onKeyDown: handleContainerKeyDown,\n }\n : {}\n\n const displayContent = React.useMemo(() => {\n if (renderDisplay) {\n return renderDisplay({ value, emptyLabel, type: resolvedType })\n }\n const baseValue = value && typeof value === 'string' ? value : ''\n const anchorClass =\n variant === 'plain'\n ? 'inline-flex max-w-full min-w-0 items-center gap-2 text-xl font-semibold leading-tight text-primary hover:text-primary/90 hover:underline'\n : 'flex max-w-full min-w-0 items-center gap-2 text-sm text-primary hover:text-primary/90 hover:underline'\n const textClass = variant === 'plain' ? 'text-2xl font-semibold leading-tight' : 'text-sm break-words'\n if (resolvedType === 'email') {\n if (!baseValue.length) {\n return (\n <p className={variant === 'plain' ? 'text-base text-muted-foreground' : 'text-sm text-muted-foreground'}>\n {emptyLabel}\n </p>\n )\n }\n return (\n <a className={anchorClass} href={`mailto:${baseValue}`}>\n <Mail aria-hidden className={variant === 'plain' ? 'h-5 w-5' : 'h-4 w-4'} />\n <span className=\"truncate min-w-0\">{baseValue}</span>\n </a>\n )\n }\n if (!baseValue.length) {\n return (\n <p className={variant === 'plain' ? 'text-base text-muted-foreground' : 'text-sm text-muted-foreground'}>\n {emptyLabel}\n </p>\n )\n }\n if (resolvedType === 'tel') {\n const sanitizedValue = baseValue.replace(/[^+\\d]/g, '')\n const hrefValue = sanitizedValue.length ? sanitizedValue : baseValue\n return (\n <a className={anchorClass} href={`tel:${hrefValue}`}>\n <Phone aria-hidden className={variant === 'plain' ? 'h-5 w-5' : 'h-4 w-4'} />\n <span className=\"truncate\">{baseValue}</span>\n </a>\n )\n }\n if (resolvedType === 'url') {\n const safeHref = resolveSafeInlineUrlHref(baseValue)\n if (!safeHref) {\n return <p className={textClass}>{baseValue}</p>\n }\n return (\n <a className={textClass} href={safeHref} target=\"_blank\" rel=\"noopener noreferrer\">\n {baseValue}\n </a>\n )\n }\n return <p className={textClass}>{baseValue}</p>\n }, [emptyLabel, renderDisplay, resolvedType, value, variant])\n\n const editingContainerClass = variant === 'plain' ? 'mt-0 w-full max-w-sm space-y-3' : 'mt-2 space-y-3'\n\n return (\n <div className={containerClasses} onClick={handleInteractiveClick}>\n <div className=\"flex items-start justify-between gap-2 min-w-0\">\n <div className={readOnlyWrapperClasses} {...interactiveProps}>\n {hideLabel ? null : <p className=\"text-xs uppercase tracking-wide text-muted-foreground\">{label}</p>}\n {editing ? (\n <form\n className={editingContainerClass}\n onSubmit={(event) => {\n event.preventDefault()\n if (!saving) void handleSave()\n }}\n onKeyDown={(event) => {\n if (event.key === 'Escape') {\n event.preventDefault()\n setEditingSafe(false)\n setError(null)\n return\n }\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n if (!saving) void handleSave()\n }\n }}\n >\n {(() => {\n const lowerType = (inputType ?? resolvedType ?? '').toLowerCase()\n const isDate = lowerType === 'date'\n const isDateTime = lowerType === 'datetime-local' || lowerType === 'datetime'\n if (resolvedType === 'tel') {\n return (\n <PhoneNumberField\n value={draft.length ? draft : undefined}\n onValueChange={(next) => {\n if (error) setError(null)\n setDraft(next ?? '')\n }}\n placeholder={placeholder}\n autoFocus\n disabled={saving}\n minDigits={7}\n />\n )\n }\n if (isDate || isDateTime) {\n let parsed: Date | null = null\n if (draft && draft.length) {\n const candidate = new Date(draft)\n if (!Number.isNaN(candidate.getTime())) parsed = candidate\n }\n return (\n <DatePicker\n value={parsed}\n onChange={(date) => {\n if (error) setError(null)\n const formatted = !date\n ? ''\n : isDateTime\n ? format(date, \"yyyy-MM-dd'T'HH:mm\")\n : format(date, 'yyyy-MM-dd')\n setDraft(formatted)\n setSaving(true)\n ;(async () => {\n try {\n await onSave(formatted.length ? formatted : null)\n setEditingSafe(false)\n } catch (err) {\n setError(resolveInlineErrorMessage(err, fallbackError, t))\n } finally {\n setSaving(false)\n }\n })()\n }}\n withTime={isDateTime}\n footer=\"apply-cancel\"\n placeholder={placeholder}\n disabled={saving}\n />\n )\n }\n return (\n <input\n className=\"w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n value={draft}\n onChange={(event) => {\n if (error) setError(null)\n setDraft(event.target.value)\n }}\n placeholder={placeholder}\n type={inputType ?? resolvedType}\n autoFocus\n />\n )\n })()}\n {error ? <p className=\"text-xs text-destructive\">{error}</p> : null}\n {renderBelowInput ? renderBelowInput({ draft, resolvedType, error, saving }) : null}\n {(() => {\n const lowerType = (inputType ?? resolvedType ?? '').toLowerCase()\n const isDateLike = lowerType === 'date' || lowerType === 'datetime-local' || lowerType === 'datetime'\n if (isDateLike) return null\n return (\n <div className=\"flex items-center gap-2\">\n <Button type=\"submit\" size=\"sm\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 h-3.5 w-3.5 animate-spin\" /> : null}\n {computedSaveLabel}\n </Button>\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => setEditingSafe(false)} disabled={saving}>\n {t('ui.detail.inline.cancel', 'Cancel')}\n </Button>\n </div>\n )\n })()}\n </form>\n ) : (\n <div className={variant === 'plain' ? '' : 'mt-1'}>{displayContent}</div>\n )}\n </div>\n {renderActions ? <div className=\"flex items-center gap-2\">{renderActions}</div> : null}\n <Button\n type=\"button\"\n variant=\"ghost\"\n size={triggerSize}\n className={triggerClasses}\n onClick={(event) => {\n event.stopPropagation()\n const next = !editing\n setEditingSafe(next)\n }}\n aria-label={triggerLabel}\n title={triggerLabel}\n >\n {editing ? <X className=\"h-4 w-4\" aria-hidden /> : <Pencil className=\"h-4 w-4\" aria-hidden />}\n </Button>\n </div>\n </div>\n )\n}\n\nexport type InlineMultilineEditorProps = {\n label: string\n value: string | null | undefined\n placeholder?: string\n emptyLabel: string\n onSave: (value: string | null) => Promise<void>\n validator?: (value: string) => string | null\n variant?: EditorVariant\n activateOnClick?: boolean\n containerClassName?: string\n triggerClassName?: string\n renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string }) => React.ReactNode\n}\n\nconst isTestEnv =\n typeof process !== 'undefined' &&\n (process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')\n\nlet markdownPluginsPromise: Promise<PluggableList> | null = null\n\nasync function loadMarkdownPlugins(): Promise<PluggableList> {\n if (isTestEnv) return []\n if (!markdownPluginsPromise) {\n markdownPluginsPromise = import('remark-gfm')\n .then((mod) => [mod.default ?? mod] as PluggableList)\n .catch(() => [])\n }\n return markdownPluginsPromise\n}\n\nexport function InlineMultilineEditor({\n label,\n value,\n placeholder,\n emptyLabel,\n onSave,\n validator,\n variant = 'default',\n activateOnClick = true,\n containerClassName,\n triggerClassName,\n renderDisplay,\n}: InlineMultilineEditorProps) {\n const t = useT()\n const [editing, setEditing] = React.useState(false)\n const [draft, setDraft] = React.useState(value ?? '')\n const [error, setError] = React.useState<string | null>(null)\n const [saving, setSaving] = React.useState(false)\n const [isMarkdownEnabled, setIsMarkdownEnabled] = React.useState(true)\n const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)\n const [markdownPlugins, setMarkdownPlugins] = React.useState<PluggableList>([])\n const fallbackError = React.useMemo(\n () => t('ui.detail.inline.error', 'Failed to save value.'),\n [t],\n )\n React.useEffect(() => {\n if (isTestEnv) return\n let mounted = true\n void loadMarkdownPlugins().then((plugins) => {\n if (!mounted) return\n setMarkdownPlugins(plugins)\n })\n return () => {\n mounted = false\n }\n }, [])\n\n const adjustTextareaSize = React.useCallback((element: HTMLTextAreaElement | null) => {\n if (!element) return\n element.style.height = 'auto'\n element.style.height = `${element.scrollHeight}px`\n }, [])\n\n React.useEffect(() => {\n adjustTextareaSize(textareaRef.current)\n }, [adjustTextareaSize, draft, isMarkdownEnabled])\n\n React.useEffect(() => {\n if (!editing) return\n const element = textareaRef.current\n if (!element) return\n window.requestAnimationFrame(() => {\n adjustTextareaSize(element)\n element.focus()\n })\n }, [adjustTextareaSize, editing, isMarkdownEnabled])\n\n const handleMarkdownToggle = React.useCallback(() => {\n setIsMarkdownEnabled((prev) => !prev)\n }, [])\n\n React.useEffect(() => {\n if (!editing) {\n setDraft(value ?? '')\n setError(null)\n }\n }, [editing, value])\n\n const handleActivate = React.useCallback(() => {\n if (!editing) setEditing(true)\n }, [editing])\n\n const handleInteractiveClick = React.useCallback(\n (event: React.MouseEvent<HTMLDivElement>) => {\n if (!activateOnClick || editing) return\n const target = event.target as HTMLElement\n const interactiveElement = target.closest('button, input, select, textarea, a, [role=\"link\"]')\n if (interactiveElement) {\n if (interactiveElement.tagName.toLowerCase() === 'a') {\n if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {\n return\n }\n event.preventDefault()\n // Links should not block activation; let the click toggle edit mode\n } else {\n return\n }\n }\n handleActivate()\n },\n [activateOnClick, editing, handleActivate],\n )\n\n const handleContainerKeyDown = React.useCallback(\n (event: React.KeyboardEvent<HTMLDivElement>) => {\n if (!activateOnClick || editing) return\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n handleActivate()\n }\n },\n [activateOnClick, editing, handleActivate],\n )\n\n const adjustError = React.useCallback(\n (nextValue: string) => {\n if (!validator) return null\n const trimmed = nextValue.trim()\n return validator(trimmed)\n },\n [validator],\n )\n\n const containerClasses = cn(\n 'group rounded-lg border p-4',\n variant === 'muted' ? 'bg-muted/30' : null,\n activateOnClick && !editing ? 'cursor-pointer' : null,\n containerClassName ?? null,\n )\n const triggerClasses = cn(\n 'transition-opacity duration-150',\n editing\n ? 'opacity-100'\n : INLINE_TRIGGER_HIDDEN_CLASSES,\n triggerClassName ?? null,\n )\n const triggerLabel = editing\n ? t('ui.detail.inline.cancel', 'Cancel')\n : t('ui.detail.inline.edit', 'Edit')\n\n const handleSave = React.useCallback(async () => {\n const trimmed = draft.trim()\n const validationError = adjustError(draft)\n if (validationError) {\n setError(validationError)\n return\n }\n setSaving(true)\n try {\n await onSave(trimmed.length ? trimmed : null)\n setEditing(false)\n } catch (err) {\n setError(resolveInlineErrorMessage(err, fallbackError, t))\n } finally {\n setSaving(false)\n }\n }, [adjustError, draft, fallbackError, onSave, t])\n\n return (\n <div className={containerClasses} onClick={handleInteractiveClick}>\n <div className=\"flex items-start justify-between gap-2\">\n <div\n className={cn('flex-1 min-w-0', activateOnClick && !editing ? 'cursor-pointer' : null)}\n {...(activateOnClick && !editing\n ? { role: 'button' as const, tabIndex: 0, onKeyDown: handleContainerKeyDown }\n : {})}\n >\n <p className=\"text-xs uppercase tracking-wide text-muted-foreground\">{label}</p>\n {editing ? (\n <form\n className=\"mt-2 space-y-3\"\n onSubmit={(event) => {\n event.preventDefault()\n if (!saving) void handleSave()\n }}\n onKeyDown={(event) => {\n if (event.key === 'Escape') {\n event.preventDefault()\n setEditing(false)\n setError(null)\n return\n }\n if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {\n event.preventDefault()\n if (!saving) void handleSave()\n }\n }}\n >\n <Textarea\n ref={textareaRef}\n rows={isMarkdownEnabled ? 8 : 3}\n className=\"w-full resize-none overflow-hidden rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n placeholder={placeholder}\n value={draft}\n onChange={(event) => {\n if (error) setError(null)\n setDraft(event.target.value)\n }}\n onInput={(event) => adjustTextareaSize(event.currentTarget)}\n autoFocus\n disabled={saving}\n />\n {error ? <p className=\"text-xs text-destructive\">{error}</p> : null}\n <div className=\"flex items-center gap-2\">\n <Button type=\"submit\" size=\"sm\" disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 h-3.5 w-3.5 animate-spin\" /> : null}\n {t('ui.detail.inline.saveShortcut', 'Save \u2318\u23CE / Ctrl+Enter')}\n </Button>\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => setEditing(false)} disabled={saving}>\n {t('ui.detail.inline.cancel', 'Cancel')}\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n onClick={handleMarkdownToggle}\n aria-pressed={isMarkdownEnabled}\n title={\n isMarkdownEnabled\n ? t('ui.detail.inline.markdownDisable', 'Disable Markdown')\n : t('ui.detail.inline.markdownEnable', 'Enable Markdown')\n }\n aria-label={\n isMarkdownEnabled\n ? t('ui.detail.inline.markdownDisable', 'Disable Markdown')\n : t('ui.detail.inline.markdownEnable', 'Enable Markdown')\n }\n className={cn('h-8 w-8', isMarkdownEnabled ? 'text-primary' : undefined)}\n disabled={saving}\n >\n <FileCode className=\"h-4 w-4\" aria-hidden />\n <span className=\"sr-only\">\n {isMarkdownEnabled\n ? t('ui.detail.inline.markdownDisable', 'Disable Markdown')\n : t('ui.detail.inline.markdownEnable', 'Enable Markdown')}\n </span>\n </Button>\n </div>\n </form>\n ) : (\n <div\n className={cn(\n 'mt-1 text-sm break-words',\n renderDisplay ? null : 'whitespace-pre-wrap',\n activateOnClick && !editing ? 'cursor-pointer' : null,\n )}\n >\n {renderDisplay ? (\n renderDisplay({ value, emptyLabel })\n ) : value && value.length ? (\n <MarkdownPreview\n remarkPlugins={markdownPlugins}\n className=\"prose prose-sm max-w-none text-foreground [&>*]:my-2 [&>*:last-child]:mb-0 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5\"\n >\n {value}\n </MarkdownPreview>\n ) : (\n <span className=\"text-muted-foreground\">{emptyLabel}</span>\n )}\n </div>\n )}\n </div>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className={triggerClasses}\n onClick={(event) => {\n event.stopPropagation()\n setEditing((state) => !state)\n }}\n aria-label={triggerLabel}\n title={triggerLabel}\n >\n {editing ? <X className=\"h-4 w-4\" aria-hidden /> : <Pencil className=\"h-4 w-4\" aria-hidden />}\n </Button>\n </div>\n </div>\n )\n}\n\nexport type InlineSelectOption = { value: string; label: string; description?: string }\n\nexport type InlineSelectEditorProps = {\n label: string\n value: string | null | undefined\n emptyLabel: string\n options: InlineSelectOption[]\n onSave: (value: string | null) => Promise<void>\n variant?: EditorVariant\n activateOnClick?: boolean\n containerClassName?: string\n triggerClassName?: string\n hideLabel?: boolean\n renderEditor?: (params: { value: string; onChange: (next: string) => void }) => React.ReactNode\n renderDisplay?: (params: { value: string | null | undefined; emptyLabel: string }) => React.ReactNode\n}\n\nexport function InlineSelectEditor({\n label,\n value,\n emptyLabel,\n options,\n onSave,\n variant = 'default',\n activateOnClick = false,\n containerClassName,\n triggerClassName,\n hideLabel = false,\n renderEditor,\n renderDisplay,\n}: InlineSelectEditorProps) {\n const t = useT()\n const [editing, setEditing] = React.useState(false)\n const [draft, setDraft] = React.useState<string>(value ?? '')\n const [saving, setSaving] = React.useState(false)\n\n React.useEffect(() => {\n if (!editing) setDraft(value ?? '')\n }, [editing, value])\n\n const containerClasses = cn(\n 'group',\n variant === 'muted'\n ? 'relative rounded border bg-muted/30 p-3'\n : variant === 'plain'\n ? 'relative flex flex-col gap-1 rounded-none border-0 p-0'\n : 'rounded-lg border bg-card p-4',\n activateOnClick && !editing ? 'cursor-pointer' : null,\n containerClassName ?? null,\n )\n const triggerClasses = cn(\n 'shrink-0 transition-opacity duration-150',\n editing\n ? 'opacity-100'\n : INLINE_TRIGGER_HIDDEN_CLASSES,\n variant === 'muted' ? 'h-8 w-8' : null,\n triggerClassName ?? null,\n )\n const triggerLabel = editing\n ? t('ui.detail.inline.cancel', 'Cancel')\n : t('ui.detail.inline.edit', 'Edit')\n\n const handleSave = React.useCallback(async () => {\n setSaving(true)\n try {\n await onSave(draft.length ? draft : null)\n setEditing(false)\n } catch (err) {\n const message = err instanceof Error ? err.message : t('ui.detail.inline.error', 'Failed to save value.')\n logger.error(message, { err })\n } finally {\n setSaving(false)\n }\n }, [draft, onSave, t])\n\n const selected = options.find((option) => option.value === value)\n\n const interactiveProps: React.HTMLAttributes<HTMLDivElement> =\n activateOnClick && !editing\n ? {\n role: 'button' as const,\n tabIndex: 0,\n onClick: () => setEditing(true),\n onKeyDown: (event) => {\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n setEditing(true)\n }\n },\n }\n : {}\n\n return (\n <div className={containerClasses}>\n <div className=\"flex items-start justify-between gap-2\">\n <div className=\"flex-1 min-w-0\" {...interactiveProps}>\n {hideLabel ? null : <p className=\"text-xs uppercase tracking-wide text-muted-foreground\">{label}</p>}\n {editing ? (\n <div className={variant === 'plain' ? 'space-y-2 pt-1' : 'mt-2 space-y-2'}>\n {renderEditor ? (\n renderEditor({ value: draft, onChange: setDraft })\n ) : (\n <Select\n value={draft || undefined}\n onValueChange={(next) => setDraft(next ?? '')}\n >\n <SelectTrigger>\n <SelectValue placeholder={t('ui.detail.inline.select.placeholder', 'Not set')} />\n </SelectTrigger>\n <SelectContent>\n {options.map((option) => (\n <SelectItem key={option.value} value={option.value}>\n {option.label}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n )}\n <div className=\"flex items-center gap-2\">\n <Button type=\"button\" size=\"sm\" onClick={() => void handleSave()} disabled={saving}>\n {saving ? <Loader2 className=\"mr-2 h-3.5 w-3.5 animate-spin\" /> : null}\n {t('ui.detail.inline.saveShortcut', 'Save \u2318\u23CE / Ctrl+Enter')}\n </Button>\n <Button type=\"button\" size=\"sm\" variant=\"ghost\" onClick={() => setEditing(false)} disabled={saving}>\n {t('ui.detail.inline.cancel', 'Cancel')}\n </Button>\n </div>\n </div>\n ) : (\n <div className={variant === 'plain' ? 'flex items-center gap-2' : 'mt-1 text-sm'}>\n {renderDisplay ? (\n renderDisplay({ value, emptyLabel })\n ) : selected ? (\n <div className=\"space-y-0.5\">\n <p className=\"font-medium leading-tight\">{selected.label}</p>\n {selected.description ? (\n <p className=\"text-xs text-muted-foreground\">{selected.description}</p>\n ) : null}\n </div>\n ) : (\n <span className=\"text-muted-foreground\">{emptyLabel}</span>\n )}\n </div>\n )}\n </div>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size={variant === 'plain' ? 'icon' : 'sm'}\n className={triggerClasses}\n onClick={(event) => {\n event.stopPropagation()\n setEditing((state) => !state)\n }}\n aria-label={triggerLabel}\n title={triggerLabel}\n >\n {editing ? <X className=\"h-4 w-4\" aria-hidden /> : <Pencil className=\"h-4 w-4\" aria-hidden />}\n </Button>\n </div>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -22,6 +22,12 @@ function ActionsDropdown({
|
|
|
22
22
|
const [anchorRect, setAnchorRect] = React.useState(null);
|
|
23
23
|
const hoverTimeoutRef = React.useRef(null);
|
|
24
24
|
const [direction, setDirection] = React.useState("down");
|
|
25
|
+
const focusOnOpenRef = React.useRef(false);
|
|
26
|
+
const deliberateOpenRef = React.useRef(false);
|
|
27
|
+
const getFocusableItems = React.useCallback(() => {
|
|
28
|
+
if (!menuRef.current) return [];
|
|
29
|
+
return Array.from(menuRef.current.querySelectorAll('[role="menuitem"]:not(:disabled)'));
|
|
30
|
+
}, []);
|
|
25
31
|
const resolvedLabel = label ?? t("ui.actions.actions", "Actions");
|
|
26
32
|
const resolvedAriaLabel = ariaLabel ?? resolvedLabel;
|
|
27
33
|
const updatePosition = React.useCallback(() => {
|
|
@@ -42,10 +48,41 @@ function ActionsDropdown({
|
|
|
42
48
|
}
|
|
43
49
|
}
|
|
44
50
|
function onKey(event) {
|
|
51
|
+
const active = document.activeElement;
|
|
52
|
+
const focusWithin = Boolean(
|
|
53
|
+
menuRef.current && active && menuRef.current.contains(active) || active && active === btnRef.current && deliberateOpenRef.current
|
|
54
|
+
);
|
|
45
55
|
if (event.key === "Escape") {
|
|
46
56
|
setOpen(false);
|
|
47
|
-
btnRef.current?.focus();
|
|
57
|
+
if (focusWithin) btnRef.current?.focus();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (!focusWithin) return;
|
|
61
|
+
const focusables = getFocusableItems();
|
|
62
|
+
if (!focusables.length) return;
|
|
63
|
+
const activeIndex = focusables.indexOf(active);
|
|
64
|
+
let nextIndex;
|
|
65
|
+
switch (event.key) {
|
|
66
|
+
case "ArrowDown":
|
|
67
|
+
nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % focusables.length;
|
|
68
|
+
break;
|
|
69
|
+
case "ArrowUp":
|
|
70
|
+
nextIndex = activeIndex < 0 ? focusables.length - 1 : (activeIndex - 1 + focusables.length) % focusables.length;
|
|
71
|
+
break;
|
|
72
|
+
case "Home":
|
|
73
|
+
nextIndex = 0;
|
|
74
|
+
break;
|
|
75
|
+
case "End":
|
|
76
|
+
nextIndex = focusables.length - 1;
|
|
77
|
+
break;
|
|
78
|
+
case "Tab":
|
|
79
|
+
nextIndex = event.shiftKey ? activeIndex <= 0 ? focusables.length - 1 : activeIndex - 1 : activeIndex < 0 || activeIndex === focusables.length - 1 ? 0 : activeIndex + 1;
|
|
80
|
+
break;
|
|
81
|
+
default:
|
|
82
|
+
return;
|
|
48
83
|
}
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
focusables[nextIndex]?.focus();
|
|
49
86
|
}
|
|
50
87
|
function onScrollOrResize() {
|
|
51
88
|
updatePosition();
|
|
@@ -60,7 +97,18 @@ function ActionsDropdown({
|
|
|
60
97
|
window.removeEventListener("scroll", onScrollOrResize, true);
|
|
61
98
|
window.removeEventListener("resize", onScrollOrResize);
|
|
62
99
|
};
|
|
63
|
-
}, [open, updatePosition]);
|
|
100
|
+
}, [open, updatePosition, getFocusableItems]);
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
if (!open) {
|
|
103
|
+
focusOnOpenRef.current = false;
|
|
104
|
+
deliberateOpenRef.current = false;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!anchorRect || !focusOnOpenRef.current) return;
|
|
108
|
+
focusOnOpenRef.current = false;
|
|
109
|
+
const [first] = getFocusableItems();
|
|
110
|
+
first?.focus();
|
|
111
|
+
}, [open, anchorRect, getFocusableItems]);
|
|
64
112
|
React.useEffect(() => {
|
|
65
113
|
return () => {
|
|
66
114
|
if (hoverTimeoutRef.current) {
|
|
@@ -104,6 +152,8 @@ function ActionsDropdown({
|
|
|
104
152
|
"aria-expanded": open,
|
|
105
153
|
"aria-label": resolvedAriaLabel,
|
|
106
154
|
onClick: () => {
|
|
155
|
+
focusOnOpenRef.current = true;
|
|
156
|
+
deliberateOpenRef.current = true;
|
|
107
157
|
setOpen((prev) => !prev);
|
|
108
158
|
requestAnimationFrame(updatePosition);
|
|
109
159
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/forms/ActionsDropdown.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport { Zap, ChevronDown, Loader2, MoreHorizontal } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type ActionItem = {\n /** Unique key */\n id: string\n /** Display label */\n label: string\n /** Lucide icon component (optional) */\n icon?: React.ComponentType<{ className?: string }>\n /** Click handler */\n onSelect: () => void\n /** Disable the item */\n disabled?: boolean\n /** Show a loading spinner instead of the icon */\n loading?: boolean\n /** Render the item in the destructive token (e.g. Delete, Clear) */\n destructive?: boolean\n}\n\n/** A visual divider between groups of items. */\nexport type ActionSeparator = {\n /** Optional key; falls back to the array index */\n id?: string\n separator: true\n}\n\n/** A muted uppercase section label grouping the items beneath it. */\nexport type ActionHeader = {\n /** Optional key; falls back to the array index */\n id?: string\n /** The section label text */\n header: string\n}\n\nexport type ActionMenuEntry = ActionItem | ActionSeparator | ActionHeader\n\nexport type ActionsDropdownProps = {\n /** Items to render inside the dropdown (may include separators) */\n items: ActionMenuEntry[]\n /** Button label (default: translated 'Actions') */\n label?: string\n /** Trigger style */\n triggerMode?: 'label' | 'icon'\n /** Accessible label for icon trigger */\n ariaLabel?: string\n /** Button size (default: 'sm') */\n size?: 'sm' | 'default'\n /** Extra classes for the trigger button (e.g. to match a dense toolbar row) */\n triggerClassName?: string\n /**\n * Leading icon inside the label-mode trigger. Defaults to `Zap`; pass a\n * component to override, or `false` to render just the label + chevron.\n */\n triggerIcon?: React.ComponentType<{ className?: string }> | false\n /**\n * Render a small warning dot on the trigger \u2014 for when an item inside the\n * menu needs attention (e.g. required fields still blank).\n */\n attention?: boolean\n}\n\nexport function ActionsDropdown({\n items,\n label,\n triggerMode = 'label',\n ariaLabel,\n size = 'sm',\n triggerClassName,\n triggerIcon,\n attention = false,\n}: ActionsDropdownProps) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const btnRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)\n const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)\n const [direction, setDirection] = React.useState<'down' | 'up'>('down')\n\n const resolvedLabel = label ?? t('ui.actions.actions', 'Actions')\n const resolvedAriaLabel = ariaLabel ?? resolvedLabel\n\n const updatePosition = React.useCallback(() => {\n if (!btnRef.current) return\n const rect = btnRef.current.getBoundingClientRect()\n setAnchorRect(rect)\n const spaceBelow = window.innerHeight - rect.bottom\n const spaceAbove = rect.top\n setDirection(spaceBelow < 200 && spaceAbove > spaceBelow ? 'up' : 'down')\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n updatePosition()\n function onDocClick(event: MouseEvent) {\n const target = event.target as Node\n if (\n menuRef.current && !menuRef.current.contains(target) &&\n btnRef.current && !btnRef.current.contains(target)\n ) {\n setOpen(false)\n }\n }\n function onKey(event: KeyboardEvent) {\n if (event.key === 'Escape') {\n setOpen(false)\n btnRef.current?.focus()\n }\n }\n function onScrollOrResize() {\n updatePosition()\n }\n document.addEventListener('mousedown', onDocClick)\n document.addEventListener('keydown', onKey)\n window.addEventListener('scroll', onScrollOrResize, true)\n window.addEventListener('resize', onScrollOrResize)\n return () => {\n document.removeEventListener('mousedown', onDocClick)\n document.removeEventListener('keydown', onKey)\n window.removeEventListener('scroll', onScrollOrResize, true)\n window.removeEventListener('resize', onScrollOrResize)\n }\n }, [open, updatePosition])\n\n React.useEffect(() => {\n return () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n }\n }, [])\n\n const handleMouseEnter = () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n setOpen(true)\n updatePosition()\n }\n\n const handleMouseLeave = (event: React.MouseEvent) => {\n const nextTarget = event.relatedTarget as Node | null\n if (nextTarget && (btnRef.current?.contains(nextTarget) || menuRef.current?.contains(nextTarget))) {\n return\n }\n hoverTimeoutRef.current = setTimeout(() => {\n setOpen(false)\n }, 150)\n }\n\n if (!items.length) return null\n\n return (\n <div\n className=\"relative inline-block\"\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n >\n <Button\n ref={btnRef}\n type=\"button\"\n variant=\"outline\"\n size={size}\n className={`${triggerMode === 'icon' ? 'px-2 ' : ''}${triggerClassName ?? ''}`.trim() || undefined}\n aria-haspopup=\"menu\"\n aria-expanded={open}\n aria-label={resolvedAriaLabel}\n onClick={() => {\n setOpen((prev) => !prev)\n requestAnimationFrame(updatePosition)\n }}\n >\n {triggerMode === 'icon' ? (\n <>\n <MoreHorizontal className=\"size-4\" />\n <span className=\"sr-only\">{resolvedAriaLabel}</span>\n </>\n ) : (\n <>\n {resolvedLabel}\n {triggerIcon === false\n ? null\n : triggerIcon\n ? React.createElement(triggerIcon, { className: 'size-4 ml-1' })\n : <Zap className=\"size-4 ml-1\" />}\n <ChevronDown className={`size-3.5 transition-transform duration-150 ${open ? 'rotate-180' : ''}`} />\n </>\n )}\n </Button>\n {attention && (\n <span\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute right-0.5 top-0.5 size-2 rounded-full bg-status-warning-icon ring-2 ring-background\"\n />\n )}\n {open && anchorRect && createPortal(\n <div\n ref={menuRef}\n role=\"menu\"\n className=\"fixed min-w-52 w-max max-w-xs rounded-md border bg-background p-1 shadow-md focus-visible:outline-none z-dropdown\"\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n style={{\n top: direction === 'down' ? anchorRect.bottom + 4 : anchorRect.top - 4,\n left: anchorRect.right,\n transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,\n }}\n >\n {items.map((item, index) => {\n if ('header' in item) {\n return (\n <div\n key={item.id ?? `header-${index}`}\n className=\"px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground\"\n >\n {item.header}\n </div>\n )\n }\n if ('separator' in item) {\n return (\n <div\n key={item.id ?? `separator-${index}`}\n role=\"separator\"\n className=\"my-1 h-px bg-border\"\n />\n )\n }\n const Icon = item.icon\n return (\n <Button\n key={item.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className={`w-full justify-start h-auto min-h-8 py-1.5 whitespace-normal text-left leading-snug${\n item.destructive\n ? ' text-destructive hover:text-destructive hover:bg-destructive/10 focus-visible:text-destructive'\n : ''\n }`}\n role=\"menuitem\"\n disabled={item.disabled}\n onClick={() => {\n setOpen(false)\n item.onSelect()\n }}\n >\n {item.loading ? (\n <Loader2 className=\"size-4 animate-spin\" />\n ) : Icon ? (\n <Icon className=\"size-4\" />\n ) : (\n <span className=\"size-4\" />\n )}\n {item.label}\n </Button>\n )\n })}\n </div>,\n document.body,\n )}\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport { Zap, ChevronDown, Loader2, MoreHorizontal } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\nexport type ActionItem = {\n /** Unique key */\n id: string\n /** Display label */\n label: string\n /** Lucide icon component (optional) */\n icon?: React.ComponentType<{ className?: string }>\n /** Click handler */\n onSelect: () => void\n /** Disable the item */\n disabled?: boolean\n /** Show a loading spinner instead of the icon */\n loading?: boolean\n /** Render the item in the destructive token (e.g. Delete, Clear) */\n destructive?: boolean\n}\n\n/** A visual divider between groups of items. */\nexport type ActionSeparator = {\n /** Optional key; falls back to the array index */\n id?: string\n separator: true\n}\n\n/** A muted uppercase section label grouping the items beneath it. */\nexport type ActionHeader = {\n /** Optional key; falls back to the array index */\n id?: string\n /** The section label text */\n header: string\n}\n\nexport type ActionMenuEntry = ActionItem | ActionSeparator | ActionHeader\n\nexport type ActionsDropdownProps = {\n /** Items to render inside the dropdown (may include separators) */\n items: ActionMenuEntry[]\n /** Button label (default: translated 'Actions') */\n label?: string\n /** Trigger style */\n triggerMode?: 'label' | 'icon'\n /** Accessible label for icon trigger */\n ariaLabel?: string\n /** Button size (default: 'sm') */\n size?: 'sm' | 'default'\n /** Extra classes for the trigger button (e.g. to match a dense toolbar row) */\n triggerClassName?: string\n /**\n * Leading icon inside the label-mode trigger. Defaults to `Zap`; pass a\n * component to override, or `false` to render just the label + chevron.\n */\n triggerIcon?: React.ComponentType<{ className?: string }> | false\n /**\n * Render a small warning dot on the trigger \u2014 for when an item inside the\n * menu needs attention (e.g. required fields still blank).\n */\n attention?: boolean\n}\n\nexport function ActionsDropdown({\n items,\n label,\n triggerMode = 'label',\n ariaLabel,\n size = 'sm',\n triggerClassName,\n triggerIcon,\n attention = false,\n}: ActionsDropdownProps) {\n const t = useT()\n const [open, setOpen] = React.useState(false)\n const btnRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)\n const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)\n const [direction, setDirection] = React.useState<'down' | 'up'>('down')\n /**\n * Whether this open should pull focus into the menu. Only a deliberate open\n * does \u2014 a click, or Enter/Space on the trigger, which the browser reports as\n * a click. A hover open must not steal focus from wherever the user is typing.\n * Consumed once `anchorRect` is measured, because the panel and its first item\n * only render after that. Same contract as RowActions (#6771).\n */\n const focusOnOpenRef = React.useRef(false)\n /**\n * Whether the current open was deliberate. A focused trigger only routes keys\n * into the menu for such an open \u2014 a hover reopen while the trigger still\n * holds focus from an earlier click must leave Tab and the arrows alone.\n */\n const deliberateOpenRef = React.useRef(false)\n\n /** The menu's enabled items in DOM order, as focusable elements. */\n const getFocusableItems = React.useCallback((): HTMLElement[] => {\n if (!menuRef.current) return []\n return Array.from(menuRef.current.querySelectorAll<HTMLElement>('[role=\"menuitem\"]:not(:disabled)'))\n }, [])\n\n const resolvedLabel = label ?? t('ui.actions.actions', 'Actions')\n const resolvedAriaLabel = ariaLabel ?? resolvedLabel\n\n const updatePosition = React.useCallback(() => {\n if (!btnRef.current) return\n const rect = btnRef.current.getBoundingClientRect()\n setAnchorRect(rect)\n const spaceBelow = window.innerHeight - rect.bottom\n const spaceAbove = rect.top\n setDirection(spaceBelow < 200 && spaceAbove > spaceBelow ? 'up' : 'down')\n }, [])\n\n React.useEffect(() => {\n if (!open) return\n updatePosition()\n function onDocClick(event: MouseEvent) {\n const target = event.target as Node\n if (\n menuRef.current && !menuRef.current.contains(target) &&\n btnRef.current && !btnRef.current.contains(target)\n ) {\n setOpen(false)\n }\n }\n function onKey(event: KeyboardEvent) {\n const active = document.activeElement\n const focusWithin = Boolean(\n (menuRef.current && active && menuRef.current.contains(active)) || (active && active === btnRef.current && deliberateOpenRef.current),\n )\n if (event.key === 'Escape') {\n setOpen(false)\n if (focusWithin) btnRef.current?.focus()\n return\n }\n if (!focusWithin) return\n const focusables = getFocusableItems()\n if (!focusables.length) return\n const activeIndex = focusables.indexOf(active as HTMLElement)\n let nextIndex: number\n switch (event.key) {\n case 'ArrowDown':\n nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % focusables.length\n break\n case 'ArrowUp':\n nextIndex = activeIndex < 0\n ? focusables.length - 1\n : (activeIndex - 1 + focusables.length) % focusables.length\n break\n case 'Home':\n nextIndex = 0\n break\n case 'End':\n nextIndex = focusables.length - 1\n break\n case 'Tab':\n nextIndex = event.shiftKey\n ? (activeIndex <= 0 ? focusables.length - 1 : activeIndex - 1)\n : (activeIndex < 0 || activeIndex === focusables.length - 1 ? 0 : activeIndex + 1)\n break\n default:\n return\n }\n event.preventDefault()\n focusables[nextIndex]?.focus()\n }\n function onScrollOrResize() {\n updatePosition()\n }\n document.addEventListener('mousedown', onDocClick)\n document.addEventListener('keydown', onKey)\n window.addEventListener('scroll', onScrollOrResize, true)\n window.addEventListener('resize', onScrollOrResize)\n return () => {\n document.removeEventListener('mousedown', onDocClick)\n document.removeEventListener('keydown', onKey)\n window.removeEventListener('scroll', onScrollOrResize, true)\n window.removeEventListener('resize', onScrollOrResize)\n }\n }, [open, updatePosition, getFocusableItems])\n\n React.useEffect(() => {\n if (!open) {\n focusOnOpenRef.current = false\n deliberateOpenRef.current = false\n return\n }\n if (!anchorRect || !focusOnOpenRef.current) return\n focusOnOpenRef.current = false\n const [first] = getFocusableItems()\n first?.focus()\n }, [open, anchorRect, getFocusableItems])\n\n React.useEffect(() => {\n return () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n }\n }, [])\n\n const handleMouseEnter = () => {\n if (hoverTimeoutRef.current) {\n clearTimeout(hoverTimeoutRef.current)\n }\n setOpen(true)\n updatePosition()\n }\n\n const handleMouseLeave = (event: React.MouseEvent) => {\n const nextTarget = event.relatedTarget as Node | null\n if (nextTarget && (btnRef.current?.contains(nextTarget) || menuRef.current?.contains(nextTarget))) {\n return\n }\n hoverTimeoutRef.current = setTimeout(() => {\n setOpen(false)\n }, 150)\n }\n\n if (!items.length) return null\n\n return (\n <div\n className=\"relative inline-block\"\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n >\n <Button\n ref={btnRef}\n type=\"button\"\n variant=\"outline\"\n size={size}\n className={`${triggerMode === 'icon' ? 'px-2 ' : ''}${triggerClassName ?? ''}`.trim() || undefined}\n aria-haspopup=\"menu\"\n aria-expanded={open}\n aria-label={resolvedAriaLabel}\n onClick={() => {\n focusOnOpenRef.current = true\n deliberateOpenRef.current = true\n setOpen((prev) => !prev)\n requestAnimationFrame(updatePosition)\n }}\n >\n {triggerMode === 'icon' ? (\n <>\n <MoreHorizontal className=\"size-4\" />\n <span className=\"sr-only\">{resolvedAriaLabel}</span>\n </>\n ) : (\n <>\n {resolvedLabel}\n {triggerIcon === false\n ? null\n : triggerIcon\n ? React.createElement(triggerIcon, { className: 'size-4 ml-1' })\n : <Zap className=\"size-4 ml-1\" />}\n <ChevronDown className={`size-3.5 transition-transform duration-150 ${open ? 'rotate-180' : ''}`} />\n </>\n )}\n </Button>\n {attention && (\n <span\n aria-hidden=\"true\"\n className=\"pointer-events-none absolute right-0.5 top-0.5 size-2 rounded-full bg-status-warning-icon ring-2 ring-background\"\n />\n )}\n {open && anchorRect && createPortal(\n <div\n ref={menuRef}\n role=\"menu\"\n className=\"fixed min-w-52 w-max max-w-xs rounded-md border bg-background p-1 shadow-md focus-visible:outline-none z-dropdown\"\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n style={{\n top: direction === 'down' ? anchorRect.bottom + 4 : anchorRect.top - 4,\n left: anchorRect.right,\n transform: `translate(-100%, ${direction === 'down' ? '0' : '-100%'})`,\n }}\n >\n {items.map((item, index) => {\n if ('header' in item) {\n return (\n <div\n key={item.id ?? `header-${index}`}\n className=\"px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground\"\n >\n {item.header}\n </div>\n )\n }\n if ('separator' in item) {\n return (\n <div\n key={item.id ?? `separator-${index}`}\n role=\"separator\"\n className=\"my-1 h-px bg-border\"\n />\n )\n }\n const Icon = item.icon\n return (\n <Button\n key={item.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className={`w-full justify-start h-auto min-h-8 py-1.5 whitespace-normal text-left leading-snug${\n item.destructive\n ? ' text-destructive hover:text-destructive hover:bg-destructive/10 focus-visible:text-destructive'\n : ''\n }`}\n role=\"menuitem\"\n disabled={item.disabled}\n onClick={() => {\n setOpen(false)\n item.onSelect()\n }}\n >\n {item.loading ? (\n <Loader2 className=\"size-4 animate-spin\" />\n ) : Icon ? (\n <Icon className=\"size-4\" />\n ) : (\n <span className=\"size-4\" />\n )}\n {item.label}\n </Button>\n )\n })}\n </div>,\n document.body,\n )}\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.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7496.1.3346415f94",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.9.1-develop.7496.1.3346415f94",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.9.1-develop.7496.1.3346415f94",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|
|
@@ -62,3 +62,108 @@ describe('ActionsDropdown', () => {
|
|
|
62
62
|
expect(onSelect).toHaveBeenCalledTimes(1)
|
|
63
63
|
})
|
|
64
64
|
})
|
|
65
|
+
|
|
66
|
+
// Regression coverage for issue #6445: the menu is portaled to the end of
|
|
67
|
+
// `document.body` and handled only Escape, so keyboard users reached its items
|
|
68
|
+
// only after tabbing through the page footer, and the arrow keys did nothing.
|
|
69
|
+
describe('ActionsDropdown — keyboard navigation (#6445)', () => {
|
|
70
|
+
const ITEMS: ActionItem[] = [
|
|
71
|
+
{ id: 'retry', label: 'Retry from the beginning', onSelect: jest.fn() },
|
|
72
|
+
{ id: 'archived', label: 'Archive', disabled: true, onSelect: jest.fn() },
|
|
73
|
+
{ id: 'export', label: 'Export', onSelect: jest.fn() },
|
|
74
|
+
{ id: 'delete', label: 'Delete', destructive: true, onSelect: jest.fn() },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
function itemNamed(name: string) {
|
|
78
|
+
return screen.getByRole('menuitem', { name })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function trigger() {
|
|
82
|
+
return screen.getByRole('button', { name: TRIGGER_LABEL })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
it('moves focus to the first enabled item when the menu opens', () => {
|
|
86
|
+
renderDropdown(ITEMS)
|
|
87
|
+
|
|
88
|
+
openMenu()
|
|
89
|
+
|
|
90
|
+
expect(itemNamed('Retry from the beginning')).toHaveFocus()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('walks the enabled items with ArrowDown/ArrowUp, wrapping and skipping disabled ones', () => {
|
|
94
|
+
renderDropdown(ITEMS)
|
|
95
|
+
openMenu()
|
|
96
|
+
|
|
97
|
+
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
|
98
|
+
expect(itemNamed('Export')).toHaveFocus()
|
|
99
|
+
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
|
100
|
+
expect(itemNamed('Delete')).toHaveFocus()
|
|
101
|
+
fireEvent.keyDown(document, { key: 'ArrowDown' })
|
|
102
|
+
expect(itemNamed('Retry from the beginning')).toHaveFocus()
|
|
103
|
+
fireEvent.keyDown(document, { key: 'ArrowUp' })
|
|
104
|
+
expect(itemNamed('Delete')).toHaveFocus()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('jumps to the first and last item with Home and End', () => {
|
|
108
|
+
renderDropdown(ITEMS)
|
|
109
|
+
openMenu()
|
|
110
|
+
|
|
111
|
+
fireEvent.keyDown(document, { key: 'End' })
|
|
112
|
+
expect(itemNamed('Delete')).toHaveFocus()
|
|
113
|
+
fireEvent.keyDown(document, { key: 'Home' })
|
|
114
|
+
expect(itemNamed('Retry from the beginning')).toHaveFocus()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('keeps Tab inside the menu instead of escaping into the rest of the page', () => {
|
|
118
|
+
renderDropdown(ITEMS)
|
|
119
|
+
openMenu()
|
|
120
|
+
|
|
121
|
+
fireEvent.keyDown(document, { key: 'Tab' })
|
|
122
|
+
expect(itemNamed('Export')).toHaveFocus()
|
|
123
|
+
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true })
|
|
124
|
+
expect(itemNamed('Retry from the beginning')).toHaveFocus()
|
|
125
|
+
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true })
|
|
126
|
+
expect(itemNamed('Delete')).toHaveFocus()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('closes on Escape and returns focus to the trigger', () => {
|
|
130
|
+
renderDropdown(ITEMS)
|
|
131
|
+
openMenu()
|
|
132
|
+
|
|
133
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
134
|
+
|
|
135
|
+
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
|
136
|
+
expect(trigger()).toHaveFocus()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('does not steal focus or swallow keys when the menu opens on hover', () => {
|
|
140
|
+
renderDropdown(ITEMS)
|
|
141
|
+
const field = document.createElement('input')
|
|
142
|
+
document.body.appendChild(field)
|
|
143
|
+
field.focus()
|
|
144
|
+
|
|
145
|
+
fireEvent.mouseEnter(trigger())
|
|
146
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
147
|
+
expect(document.activeElement).toBe(field)
|
|
148
|
+
|
|
149
|
+
for (const key of ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Tab']) {
|
|
150
|
+
fireEvent.keyDown(document, { key })
|
|
151
|
+
expect(document.activeElement).toBe(field)
|
|
152
|
+
}
|
|
153
|
+
field.remove()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('leaves Tab alone when a hover reopens the menu while the trigger still has focus', () => {
|
|
157
|
+
renderDropdown(ITEMS)
|
|
158
|
+
trigger().focus()
|
|
159
|
+
|
|
160
|
+
fireEvent.mouseEnter(trigger())
|
|
161
|
+
expect(screen.getByRole('menu')).toBeInTheDocument()
|
|
162
|
+
|
|
163
|
+
for (const key of ['Tab', 'ArrowDown']) {
|
|
164
|
+
const notPrevented = fireEvent.keyDown(document, { key })
|
|
165
|
+
expect(notPrevented).toBe(true)
|
|
166
|
+
expect(trigger()).toHaveFocus()
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
|
|
4
|
+
const packageRoot = path.resolve(__dirname, '../../..')
|
|
5
|
+
const srcRoot = path.join(packageRoot, 'src')
|
|
6
|
+
|
|
7
|
+
const collectSourceFiles = (dir: string): string[] =>
|
|
8
|
+
fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
9
|
+
const fullPath = path.join(dir, entry.name)
|
|
10
|
+
if (entry.isDirectory()) {
|
|
11
|
+
return entry.name === '__tests__' ? [] : collectSourceFiles(fullPath)
|
|
12
|
+
}
|
|
13
|
+
return /\.(ts|tsx)$/.test(entry.name) ? [fullPath] : []
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('next/dynamic import specifier (#7102)', () => {
|
|
17
|
+
it('imports the exact next/dynamic specifier so the Next compiler transform still applies', () => {
|
|
18
|
+
const offenders = collectSourceFiles(srcRoot)
|
|
19
|
+
.filter((file) => /from\s+['"]next\/dynamic\.js['"]/.test(fs.readFileSync(file, 'utf8')))
|
|
20
|
+
.map((file) => path.relative(packageRoot, file))
|
|
21
|
+
expect(offenders).toEqual([])
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -24,6 +24,13 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
|
24
24
|
|
|
25
25
|
const logger = createLogger('ui')
|
|
26
26
|
|
|
27
|
+
// Coarse-pointer devices never fire hover, so the hover-only reveal would leave the
|
|
28
|
+
// edit trigger permanently at opacity-0 and untappable. Keep it visible there instead.
|
|
29
|
+
// Exported so sibling inline editors outside this file share one definition rather than
|
|
30
|
+
// re-deriving it — the drift that left these triggers hover-only in the first place.
|
|
31
|
+
export const INLINE_TRIGGER_HIDDEN_CLASSES =
|
|
32
|
+
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100'
|
|
33
|
+
|
|
27
34
|
// Server validation errors arrive as stable i18n keys (e.g. `customers.people.form.*`), the
|
|
28
35
|
// same contract CrudForm resolves via translateValidationMessage; without this they render
|
|
29
36
|
// raw. `t(msg, msg)` is a no-op for already-localized strings.
|
|
@@ -170,11 +177,14 @@ export function InlineTextEditor({
|
|
|
170
177
|
'shrink-0 transition-opacity duration-150',
|
|
171
178
|
editing
|
|
172
179
|
? 'opacity-100'
|
|
173
|
-
:
|
|
180
|
+
: INLINE_TRIGGER_HIDDEN_CLASSES,
|
|
174
181
|
variant === 'muted' ? 'h-8 w-8' : null,
|
|
175
182
|
triggerClassName ?? null,
|
|
176
183
|
)
|
|
177
184
|
const triggerSize = variant === 'plain' ? 'icon' : 'sm'
|
|
185
|
+
const triggerLabel = editing
|
|
186
|
+
? t('ui.detail.inline.cancel', 'Cancel')
|
|
187
|
+
: t('ui.detail.inline.edit', 'Edit')
|
|
178
188
|
|
|
179
189
|
const setEditingSafe = React.useCallback(
|
|
180
190
|
(next: boolean) => {
|
|
@@ -436,8 +446,10 @@ export function InlineTextEditor({
|
|
|
436
446
|
const next = !editing
|
|
437
447
|
setEditingSafe(next)
|
|
438
448
|
}}
|
|
449
|
+
aria-label={triggerLabel}
|
|
450
|
+
title={triggerLabel}
|
|
439
451
|
>
|
|
440
|
-
{editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
|
452
|
+
{editing ? <X className="h-4 w-4" aria-hidden /> : <Pencil className="h-4 w-4" aria-hidden />}
|
|
441
453
|
</Button>
|
|
442
454
|
</div>
|
|
443
455
|
</div>
|
|
@@ -597,9 +609,12 @@ export function InlineMultilineEditor({
|
|
|
597
609
|
'transition-opacity duration-150',
|
|
598
610
|
editing
|
|
599
611
|
? 'opacity-100'
|
|
600
|
-
:
|
|
612
|
+
: INLINE_TRIGGER_HIDDEN_CLASSES,
|
|
601
613
|
triggerClassName ?? null,
|
|
602
614
|
)
|
|
615
|
+
const triggerLabel = editing
|
|
616
|
+
? t('ui.detail.inline.cancel', 'Cancel')
|
|
617
|
+
: t('ui.detail.inline.edit', 'Edit')
|
|
603
618
|
|
|
604
619
|
const handleSave = React.useCallback(async () => {
|
|
605
620
|
const trimmed = draft.trim()
|
|
@@ -732,8 +747,10 @@ export function InlineMultilineEditor({
|
|
|
732
747
|
event.stopPropagation()
|
|
733
748
|
setEditing((state) => !state)
|
|
734
749
|
}}
|
|
750
|
+
aria-label={triggerLabel}
|
|
751
|
+
title={triggerLabel}
|
|
735
752
|
>
|
|
736
|
-
{editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
|
753
|
+
{editing ? <X className="h-4 w-4" aria-hidden /> : <Pencil className="h-4 w-4" aria-hidden />}
|
|
737
754
|
</Button>
|
|
738
755
|
</div>
|
|
739
756
|
</div>
|
|
@@ -794,10 +811,13 @@ export function InlineSelectEditor({
|
|
|
794
811
|
'shrink-0 transition-opacity duration-150',
|
|
795
812
|
editing
|
|
796
813
|
? 'opacity-100'
|
|
797
|
-
:
|
|
814
|
+
: INLINE_TRIGGER_HIDDEN_CLASSES,
|
|
798
815
|
variant === 'muted' ? 'h-8 w-8' : null,
|
|
799
816
|
triggerClassName ?? null,
|
|
800
817
|
)
|
|
818
|
+
const triggerLabel = editing
|
|
819
|
+
? t('ui.detail.inline.cancel', 'Cancel')
|
|
820
|
+
: t('ui.detail.inline.edit', 'Edit')
|
|
801
821
|
|
|
802
822
|
const handleSave = React.useCallback(async () => {
|
|
803
823
|
setSaving(true)
|
|
@@ -891,8 +911,10 @@ export function InlineSelectEditor({
|
|
|
891
911
|
event.stopPropagation()
|
|
892
912
|
setEditing((state) => !state)
|
|
893
913
|
}}
|
|
914
|
+
aria-label={triggerLabel}
|
|
915
|
+
title={triggerLabel}
|
|
894
916
|
>
|
|
895
|
-
{editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
|
917
|
+
{editing ? <X className="h-4 w-4" aria-hidden /> : <Pencil className="h-4 w-4" aria-hidden />}
|
|
896
918
|
</Button>
|
|
897
919
|
</div>
|
|
898
920
|
</div>
|
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
import * as React from 'react'
|
|
6
6
|
import { act, fireEvent, screen } from '@testing-library/react'
|
|
7
7
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
InlineMultilineEditor,
|
|
10
|
+
InlineSelectEditor,
|
|
11
|
+
InlineTextEditor,
|
|
12
|
+
resolveSafeInlineUrlHref,
|
|
13
|
+
} from '../InlineEditors'
|
|
9
14
|
|
|
10
15
|
describe('InlineTextEditor URL display', () => {
|
|
11
16
|
it('renders allowed URL protocols as links', () => {
|
|
@@ -44,11 +49,9 @@ describe('InlineTextEditor URL display', () => {
|
|
|
44
49
|
|
|
45
50
|
describe('InlineTextEditor date types — DS unification (no native date input)', () => {
|
|
46
51
|
function activateEditor() {
|
|
47
|
-
// Pencil/edit toggle is the trailing icon-only button on the InlineTextEditor
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
expect(buttons.length).toBeGreaterThan(0)
|
|
51
|
-
const editToggle = buttons[buttons.length - 1] as HTMLButtonElement
|
|
52
|
+
// Pencil/edit toggle is the trailing icon-only button on the InlineTextEditor,
|
|
53
|
+
// reachable by its accessible name.
|
|
54
|
+
const editToggle = screen.getByRole('button', { name: 'Edit' })
|
|
52
55
|
act(() => {
|
|
53
56
|
fireEvent.click(editToggle)
|
|
54
57
|
})
|
|
@@ -122,6 +125,118 @@ describe('InlineTextEditor date types — DS unification (no native date input)'
|
|
|
122
125
|
})
|
|
123
126
|
})
|
|
124
127
|
|
|
128
|
+
describe('inline editor trigger accessibility and touch reachability (#5947)', () => {
|
|
129
|
+
const editors = [
|
|
130
|
+
{
|
|
131
|
+
name: 'InlineTextEditor',
|
|
132
|
+
render: () =>
|
|
133
|
+
renderWithProviders(
|
|
134
|
+
<InlineTextEditor label="Email" value="a@example.com" emptyLabel="No email" onSave={jest.fn()} />,
|
|
135
|
+
{ dict: {} },
|
|
136
|
+
),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'InlineMultilineEditor',
|
|
140
|
+
render: () =>
|
|
141
|
+
renderWithProviders(
|
|
142
|
+
<InlineMultilineEditor label="Notes" value="Hello" emptyLabel="No notes" onSave={jest.fn()} />,
|
|
143
|
+
{ dict: {} },
|
|
144
|
+
),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'InlineSelectEditor',
|
|
148
|
+
render: () =>
|
|
149
|
+
renderWithProviders(
|
|
150
|
+
<InlineSelectEditor
|
|
151
|
+
label="Status"
|
|
152
|
+
value="active"
|
|
153
|
+
emptyLabel="No status"
|
|
154
|
+
options={[{ value: 'active', label: 'Active' }]}
|
|
155
|
+
onSave={jest.fn()}
|
|
156
|
+
/>,
|
|
157
|
+
{ dict: {} },
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
it.each(editors)('$name exposes an accessible name on its edit trigger', ({ render }) => {
|
|
163
|
+
render()
|
|
164
|
+
|
|
165
|
+
const trigger = screen.getByRole('button', { name: 'Edit' })
|
|
166
|
+
expect(trigger).toBeInTheDocument()
|
|
167
|
+
expect(trigger).toHaveAttribute('aria-label', 'Edit')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it.each(editors)('$name relabels the trigger to Cancel while editing', ({ render }) => {
|
|
171
|
+
render()
|
|
172
|
+
|
|
173
|
+
act(() => {
|
|
174
|
+
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// The open form renders its own textual Cancel button with the same action, so match
|
|
178
|
+
// the icon-only toggle by the aria-label this fix adds rather than by name alone.
|
|
179
|
+
const labelledTriggers = screen
|
|
180
|
+
.getAllByRole('button', { name: 'Cancel' })
|
|
181
|
+
.filter((button) => button.getAttribute('aria-label') === 'Cancel')
|
|
182
|
+
expect(labelledTriggers).toHaveLength(1)
|
|
183
|
+
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it.each(editors)('$name reveals the trigger on coarse-pointer devices that never hover', ({ render }) => {
|
|
187
|
+
render()
|
|
188
|
+
|
|
189
|
+
const trigger = screen.getByRole('button', { name: 'Edit' })
|
|
190
|
+
// Without this variant the trigger stays at opacity-0 forever on touch, since no
|
|
191
|
+
// hover event exists to satisfy group-hover: and it is not focused before the tap.
|
|
192
|
+
expect(trigger.className).toContain('[@media(hover:none)]:opacity-100')
|
|
193
|
+
expect(trigger.className).toContain('group-hover:opacity-100')
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('keeps a caller-supplied triggerClassName able to override the default reveal', () => {
|
|
197
|
+
renderWithProviders(
|
|
198
|
+
<InlineTextEditor
|
|
199
|
+
label="Email"
|
|
200
|
+
value="a@example.com"
|
|
201
|
+
emptyLabel="No email"
|
|
202
|
+
triggerClassName="opacity-100"
|
|
203
|
+
onSave={jest.fn()}
|
|
204
|
+
/>,
|
|
205
|
+
{ dict: {} },
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
// Substring matching would hold on the default constant alone, which already carries
|
|
209
|
+
// group-hover:opacity-100 — tokenise the class list so the override is really pinned.
|
|
210
|
+
const classNames = screen.getByRole('button', { name: 'Edit' }).className.split(/\s+/)
|
|
211
|
+
expect(classNames).toContain('opacity-100')
|
|
212
|
+
expect(classNames).not.toContain('opacity-0')
|
|
213
|
+
expect(classNames).toContain('[@media(hover:none)]:opacity-100')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('keeps the coarse-pointer reveal when a caller supplies its own hover-only reveal', () => {
|
|
217
|
+
// The literal string CompanyHighlights, PersonHighlights and the sales document page
|
|
218
|
+
// pass. For those fields the touch fix only reaches the browser because tailwind-merge
|
|
219
|
+
// does not treat their unprefixed opacity-0 as conflicting with the arbitrary variant.
|
|
220
|
+
renderWithProviders(
|
|
221
|
+
<InlineTextEditor
|
|
222
|
+
label="Email"
|
|
223
|
+
value="a@example.com"
|
|
224
|
+
emptyLabel="No email"
|
|
225
|
+
triggerClassName="opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 mt-1"
|
|
226
|
+
onSave={jest.fn()}
|
|
227
|
+
/>,
|
|
228
|
+
{ dict: {} },
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
const classNames = screen.getByRole('button', { name: 'Edit' }).className.split(/\s+/)
|
|
232
|
+
// mt-1 comes only from the caller, so it proves the override really reached the merge
|
|
233
|
+
// rather than the assertions below passing on the default constant alone.
|
|
234
|
+
expect(classNames).toContain('mt-1')
|
|
235
|
+
expect(classNames).toContain('[@media(hover:none)]:opacity-100')
|
|
236
|
+
expect(classNames).toContain('group-hover:opacity-100')
|
|
237
|
+
})
|
|
238
|
+
})
|
|
239
|
+
|
|
125
240
|
describe('resolveSafeInlineUrlHref', () => {
|
|
126
241
|
it.each(['http://example.com', 'https://example.com', 'mailto:user@example.com', 'tel:+48123456789'])(
|
|
127
242
|
'allows %s',
|
|
@@ -82,6 +82,26 @@ export function ActionsDropdown({
|
|
|
82
82
|
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)
|
|
83
83
|
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)
|
|
84
84
|
const [direction, setDirection] = React.useState<'down' | 'up'>('down')
|
|
85
|
+
/**
|
|
86
|
+
* Whether this open should pull focus into the menu. Only a deliberate open
|
|
87
|
+
* does — a click, or Enter/Space on the trigger, which the browser reports as
|
|
88
|
+
* a click. A hover open must not steal focus from wherever the user is typing.
|
|
89
|
+
* Consumed once `anchorRect` is measured, because the panel and its first item
|
|
90
|
+
* only render after that. Same contract as RowActions (#6771).
|
|
91
|
+
*/
|
|
92
|
+
const focusOnOpenRef = React.useRef(false)
|
|
93
|
+
/**
|
|
94
|
+
* Whether the current open was deliberate. A focused trigger only routes keys
|
|
95
|
+
* into the menu for such an open — a hover reopen while the trigger still
|
|
96
|
+
* holds focus from an earlier click must leave Tab and the arrows alone.
|
|
97
|
+
*/
|
|
98
|
+
const deliberateOpenRef = React.useRef(false)
|
|
99
|
+
|
|
100
|
+
/** The menu's enabled items in DOM order, as focusable elements. */
|
|
101
|
+
const getFocusableItems = React.useCallback((): HTMLElement[] => {
|
|
102
|
+
if (!menuRef.current) return []
|
|
103
|
+
return Array.from(menuRef.current.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'))
|
|
104
|
+
}, [])
|
|
85
105
|
|
|
86
106
|
const resolvedLabel = label ?? t('ui.actions.actions', 'Actions')
|
|
87
107
|
const resolvedAriaLabel = ariaLabel ?? resolvedLabel
|
|
@@ -108,10 +128,45 @@ export function ActionsDropdown({
|
|
|
108
128
|
}
|
|
109
129
|
}
|
|
110
130
|
function onKey(event: KeyboardEvent) {
|
|
131
|
+
const active = document.activeElement
|
|
132
|
+
const focusWithin = Boolean(
|
|
133
|
+
(menuRef.current && active && menuRef.current.contains(active)) || (active && active === btnRef.current && deliberateOpenRef.current),
|
|
134
|
+
)
|
|
111
135
|
if (event.key === 'Escape') {
|
|
112
136
|
setOpen(false)
|
|
113
|
-
btnRef.current?.focus()
|
|
137
|
+
if (focusWithin) btnRef.current?.focus()
|
|
138
|
+
return
|
|
114
139
|
}
|
|
140
|
+
if (!focusWithin) return
|
|
141
|
+
const focusables = getFocusableItems()
|
|
142
|
+
if (!focusables.length) return
|
|
143
|
+
const activeIndex = focusables.indexOf(active as HTMLElement)
|
|
144
|
+
let nextIndex: number
|
|
145
|
+
switch (event.key) {
|
|
146
|
+
case 'ArrowDown':
|
|
147
|
+
nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % focusables.length
|
|
148
|
+
break
|
|
149
|
+
case 'ArrowUp':
|
|
150
|
+
nextIndex = activeIndex < 0
|
|
151
|
+
? focusables.length - 1
|
|
152
|
+
: (activeIndex - 1 + focusables.length) % focusables.length
|
|
153
|
+
break
|
|
154
|
+
case 'Home':
|
|
155
|
+
nextIndex = 0
|
|
156
|
+
break
|
|
157
|
+
case 'End':
|
|
158
|
+
nextIndex = focusables.length - 1
|
|
159
|
+
break
|
|
160
|
+
case 'Tab':
|
|
161
|
+
nextIndex = event.shiftKey
|
|
162
|
+
? (activeIndex <= 0 ? focusables.length - 1 : activeIndex - 1)
|
|
163
|
+
: (activeIndex < 0 || activeIndex === focusables.length - 1 ? 0 : activeIndex + 1)
|
|
164
|
+
break
|
|
165
|
+
default:
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
event.preventDefault()
|
|
169
|
+
focusables[nextIndex]?.focus()
|
|
115
170
|
}
|
|
116
171
|
function onScrollOrResize() {
|
|
117
172
|
updatePosition()
|
|
@@ -126,7 +181,19 @@ export function ActionsDropdown({
|
|
|
126
181
|
window.removeEventListener('scroll', onScrollOrResize, true)
|
|
127
182
|
window.removeEventListener('resize', onScrollOrResize)
|
|
128
183
|
}
|
|
129
|
-
}, [open, updatePosition])
|
|
184
|
+
}, [open, updatePosition, getFocusableItems])
|
|
185
|
+
|
|
186
|
+
React.useEffect(() => {
|
|
187
|
+
if (!open) {
|
|
188
|
+
focusOnOpenRef.current = false
|
|
189
|
+
deliberateOpenRef.current = false
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
if (!anchorRect || !focusOnOpenRef.current) return
|
|
193
|
+
focusOnOpenRef.current = false
|
|
194
|
+
const [first] = getFocusableItems()
|
|
195
|
+
first?.focus()
|
|
196
|
+
}, [open, anchorRect, getFocusableItems])
|
|
130
197
|
|
|
131
198
|
React.useEffect(() => {
|
|
132
199
|
return () => {
|
|
@@ -172,6 +239,8 @@ export function ActionsDropdown({
|
|
|
172
239
|
aria-expanded={open}
|
|
173
240
|
aria-label={resolvedAriaLabel}
|
|
174
241
|
onClick={() => {
|
|
242
|
+
focusOnOpenRef.current = true
|
|
243
|
+
deliberateOpenRef.current = true
|
|
175
244
|
setOpen((prev) => !prev)
|
|
176
245
|
requestAnimationFrame(updatePosition)
|
|
177
246
|
}}
|