mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
improvement(ui): standardize modal default actions (#7029)
* improvement(ui): standardize modal default actions * fix(ui): keep aggregate deletes on safe default
This commit is contained in:
@@ -33,6 +33,16 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items
|
||||
- **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label.
|
||||
- **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead.
|
||||
|
||||
## Modal keyboard defaults
|
||||
|
||||
Declare keyboard intent on the action-owning primitive; never add document-level or per-callsite Enter listeners.
|
||||
|
||||
- `ChipModalFooter` defaults to `defaultAction='primary'`. A plain Enter in a canonical single-line field or a custom plain input invokes the enabled primary action. Use `'none'` when submission must require an explicit click, such as an irreversible destructive action or an editor whose nested control owns Enter. Use `'dismiss'` only when dismissal is genuinely the modal's default decision.
|
||||
- `ChipConfirmModal` fails safe with `defaultAction='dismiss'`. Opt into `'confirm'` only for an audited, low-impact reversible or non-destructive decision. Deleting an aggregate resource such as a workflow, table, knowledge base, or folder remains `'dismiss'` even when it can be restored, because the action takes a broad dependent graph offline. Use `'none'` for typed confirmations and severe account, ownership, or access changes. Button color never determines keyboard behavior.
|
||||
- Textareas, native forms, buttons, links, comboboxes, menus, listboxes, tag/email inputs, IME composition, modified Enter, and disabled or pending actions retain their native behavior. A native form remains the sole submission path so browser validation is not bypassed.
|
||||
- A custom field containing a search, token editor, or another input that owns Enter must set `submitOnEnter={false}` on `ChipModalField`. Do not attach a duplicate `onKeyDown` handler merely to call the footer action.
|
||||
- Initial focus goes to the first visible editable text control. With no text control, the declared real button receives focus; `'none'` focuses the dialog surface. A safe dismiss default never turns Enter in a text field into data loss—the field simply does not publish a submit action.
|
||||
|
||||
## Authoring principles
|
||||
|
||||
- **One source of truth for shared chrome.** Compose from `chip-chrome.ts` / `chipVariants`; never duplicate the chrome string.
|
||||
|
||||
@@ -464,9 +464,6 @@ export default function LoginPage({
|
||||
title='Email'
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(value) => setForgotPasswordEmail(value)}
|
||||
onSubmit={() => {
|
||||
if (!isSubmittingReset) void handleForgotPassword()
|
||||
}}
|
||||
required
|
||||
placeholder='you@example.com'
|
||||
/>
|
||||
|
||||
@@ -48,6 +48,13 @@ const FALLBACK_STATUS: ProviderStatus = {
|
||||
const SOCIAL_BTN =
|
||||
'relative flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--border-1)] text-[13.5px] text-[var(--text-primary)] transition-colors hover:bg-[var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
/** Auth providers are peer choices, so opening the dialog must not arm one or dismissal. */
|
||||
function focusAuthDialog(event: Event): void {
|
||||
event.preventDefault()
|
||||
const content = event.currentTarget as HTMLElement | null
|
||||
content?.focus()
|
||||
}
|
||||
|
||||
function fetchProviderStatus(): Promise<ProviderStatus> {
|
||||
if (fetchPromise) return fetchPromise
|
||||
fetchPromise = requestJson(getAuthProvidersContract, {})
|
||||
@@ -155,7 +162,11 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent size='sm' className='dark bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<ModalContent
|
||||
size='sm'
|
||||
className='dark bg-[var(--bg)] text-[var(--text-primary)]'
|
||||
onOpenAutoFocus={focusAuthDialog}
|
||||
>
|
||||
<ModalTitle className='sr-only'>
|
||||
{effectiveView === 'login' ? 'Log in' : 'Create account'}
|
||||
</ModalTitle>
|
||||
|
||||
+1
@@ -39,6 +39,7 @@ export const DeleteConfirmModal = memo(function DeleteConfirmModal({
|
||||
onOpenChange={onOpenChange}
|
||||
srTitle={title}
|
||||
title={title}
|
||||
defaultAction={totalCount === 1 && !hasFolders ? 'confirm' : 'dismiss'}
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
fileName
|
||||
|
||||
@@ -252,6 +252,7 @@ export function ShareModal({
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={handleClose}
|
||||
defaultAction={isUnshareAction ? 'none' : 'primary'}
|
||||
secondaryActions={
|
||||
saved?.isActive && saved.url
|
||||
? [
|
||||
|
||||
@@ -2297,6 +2297,7 @@ export function Files() {
|
||||
open={Boolean(extractTarget)}
|
||||
onOpenChange={(open) => !open && setExtractTargetId(null)}
|
||||
title='Unzip archive?'
|
||||
defaultAction='confirm'
|
||||
text={[
|
||||
'This will unzip ',
|
||||
{ text: extractTarget?.name ?? 'this archive', bold: true },
|
||||
|
||||
+2
-1
@@ -381,7 +381,7 @@ export function DocumentTagsModal({
|
||||
<ChipModalHeader onClose={() => handleClose(false)}>Document Tags</ChipModalHeader>
|
||||
|
||||
<ChipModalBody>
|
||||
<ChipModalField type='custom' title='Tags'>
|
||||
<ChipModalField type='custom' title='Tags' submitOnEnter={false}>
|
||||
<div className='space-y-2'>
|
||||
{documentTags.map((tag, index) => (
|
||||
<div key={tag.displayName} className='space-y-2'>
|
||||
@@ -737,6 +737,7 @@ export function DocumentTagsModal({
|
||||
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleClose(false)}
|
||||
defaultAction='none'
|
||||
primaryAction={{ label: 'Close', onClick: () => handleClose(false) }}
|
||||
/>
|
||||
</ChipModal>
|
||||
|
||||
@@ -1372,6 +1372,7 @@ export function KnowledgeBase({
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
srTitle='Delete Knowledge Base'
|
||||
title='Delete Knowledge Base'
|
||||
defaultAction='dismiss'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
{ text: knowledgeBaseName, bold: true },
|
||||
|
||||
+2
@@ -248,6 +248,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='custom'
|
||||
submitOnEnter={false}
|
||||
title={
|
||||
<>
|
||||
Tags:{' '}
|
||||
@@ -389,6 +390,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
<ChipModalFooter
|
||||
onCancel={() => handleClose(false)}
|
||||
defaultAction='none'
|
||||
primaryAction={{ label: 'Close', onClick: () => handleClose(false) }}
|
||||
/>
|
||||
</ChipModal>
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
|
||||
onOpenChange={onClose}
|
||||
srTitle='Delete Knowledge Base'
|
||||
title='Delete Knowledge Base'
|
||||
defaultAction='dismiss'
|
||||
text={
|
||||
knowledgeBaseName
|
||||
? [
|
||||
|
||||
+18
-18
@@ -1,8 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useId, useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalHeader,
|
||||
cn,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -10,11 +13,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
Duplicate,
|
||||
Loader,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
} from '@sim/emcn'
|
||||
import { CircleAlert } from '@sim/emcn/icons'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -62,6 +60,7 @@ export function ExecutionSnapshot({
|
||||
onClose = () => {},
|
||||
}: ExecutionSnapshotProps) {
|
||||
const { data, isLoading, error } = useExecutionSnapshot(executionId)
|
||||
const modalDescriptionId = useId()
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
|
||||
@@ -205,25 +204,26 @@ export function ExecutionSnapshot({
|
||||
if (isModal) {
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
<ChipModal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
srTitle='Workflow State'
|
||||
aria-describedby={modalDescriptionId}
|
||||
size='full'
|
||||
className='h-[90vh] [&>div]:h-full'
|
||||
>
|
||||
<ModalContent size='full' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>Workflow State</ModalHeader>
|
||||
|
||||
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>
|
||||
<ModalDescription className='sr-only'>
|
||||
View the workflow state snapshot for this execution
|
||||
</ModalDescription>
|
||||
{renderContent()}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<ChipModalHeader onClose={onClose}>Workflow State</ChipModalHeader>
|
||||
<ChipModalBody fullBleed>
|
||||
<p id={modalDescriptionId} className='sr-only'>
|
||||
View the workflow state snapshot for this execution
|
||||
</p>
|
||||
{renderContent()}
|
||||
</ChipModalBody>
|
||||
</ChipModal>
|
||||
{canvasContextMenu}
|
||||
</>
|
||||
)
|
||||
|
||||
+11
-15
@@ -11,27 +11,23 @@ const { mockToastError } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
Loader: () => <span aria-hidden='true' />,
|
||||
Modal: ({
|
||||
ChipModal: ({
|
||||
children,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: ReactNode
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) =>
|
||||
open ? (
|
||||
<div>
|
||||
{children}
|
||||
<button type='button' onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
ModalBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ModalContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ModalDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
ModalHeader: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||||
}) => (open ? <div>{children}</div> : null),
|
||||
ChipModalBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
ChipModalHeader: ({ children, onClose }: { children: ReactNode; onClose: () => void }) => (
|
||||
<h2>
|
||||
{children}
|
||||
<button type='button' onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</h2>
|
||||
),
|
||||
toast: { error: mockToastError },
|
||||
}))
|
||||
|
||||
|
||||
+20
-24
@@ -1,15 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import {
|
||||
Loader,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
import { Component, type ErrorInfo, type ReactNode, useId } from 'react'
|
||||
import { ChipModal, ChipModalBody, ChipModalHeader, Loader, toast } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('ExecutionSnapshotBoundary')
|
||||
@@ -32,26 +24,30 @@ interface SnapshotModalFallbackProps {
|
||||
}
|
||||
|
||||
export function SnapshotModalFallback({ isOpen, onClose }: SnapshotModalFallbackProps) {
|
||||
const descriptionId = useId()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<ChipModal
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose()
|
||||
}}
|
||||
srTitle='Workflow State'
|
||||
aria-describedby={descriptionId}
|
||||
size='full'
|
||||
className='h-[90vh] [&>div]:h-full'
|
||||
>
|
||||
<ModalContent size='full' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>Workflow State</ModalHeader>
|
||||
<ModalBody className='!p-0 flex min-h-0 flex-1 items-center justify-center overflow-hidden'>
|
||||
<ModalDescription className='sr-only'>
|
||||
Loading the workflow state snapshot for this execution
|
||||
</ModalDescription>
|
||||
<div className='flex items-center gap-2 text-[var(--text-secondary)]'>
|
||||
<Loader className='size-[16px]' animate />
|
||||
<span className='text-small'>Loading run snapshot…</span>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<ChipModalHeader onClose={onClose}>Workflow State</ChipModalHeader>
|
||||
<ChipModalBody fullBleed className='items-center justify-center'>
|
||||
<p id={descriptionId} className='sr-only'>
|
||||
Loading the workflow state snapshot for this execution
|
||||
</p>
|
||||
<div className='flex items-center gap-2 text-[var(--text-secondary)]'>
|
||||
<Loader className='size-[16px]' animate />
|
||||
<span className='text-small'>Loading run snapshot…</span>
|
||||
</div>
|
||||
</ChipModalBody>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -486,6 +486,7 @@ export function Admin() {
|
||||
}}
|
||||
srTitle='Ban user'
|
||||
title='Ban user'
|
||||
defaultAction='none'
|
||||
text={[
|
||||
'Banning ',
|
||||
{ text: pendingUser?.email ?? 'this user', bold: true },
|
||||
|
||||
@@ -263,6 +263,7 @@ export function Browser() {
|
||||
open={confirming !== null}
|
||||
onOpenChange={(open) => !open && setConfirming(null)}
|
||||
title={target ? target.action : 'Clear all browsing data'}
|
||||
defaultAction={target?.kind === 'cache' ? 'confirm' : 'dismiss'}
|
||||
text={[
|
||||
'This will ',
|
||||
{
|
||||
|
||||
@@ -443,9 +443,6 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
|
||||
}}
|
||||
placeholder={editingMeta?.placeholder}
|
||||
className={CHIP_FIELD_INPUT}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave()
|
||||
}}
|
||||
name='byok_api_key'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
@@ -472,7 +469,6 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
|
||||
onChange={setNameInput}
|
||||
placeholder='e.g. Production key'
|
||||
maxLength={120}
|
||||
onSubmit={handleSave}
|
||||
/>
|
||||
)}
|
||||
<ChipModalError>{error}</ChipModalError>
|
||||
|
||||
+1
@@ -96,6 +96,7 @@ export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountM
|
||||
}}
|
||||
size='md'
|
||||
title='Delete account'
|
||||
defaultAction='none'
|
||||
confirm={{
|
||||
label: 'Delete account',
|
||||
pendingLabel: 'Deleting...',
|
||||
|
||||
+1
-1
@@ -362,7 +362,6 @@ export function InboxSettingsTab() {
|
||||
setNewUsername(value)
|
||||
if (editAddressError) setEditAddressError(null)
|
||||
}}
|
||||
onSubmit={handleEditAddress}
|
||||
placeholder='e.g., new-acme'
|
||||
error={editAddressError}
|
||||
/>
|
||||
@@ -370,6 +369,7 @@ export function InboxSettingsTab() {
|
||||
<ChipModalFooter
|
||||
onCancel={() => setIsEditAddressOpen(false)}
|
||||
cancelDisabled={updateAddress.isPending}
|
||||
defaultAction='none'
|
||||
primaryAction={{
|
||||
label: updateAddress.isPending ? 'Updating...' : 'Change address',
|
||||
onClick: handleEditAddress,
|
||||
|
||||
+4
-1
@@ -151,7 +151,10 @@ function FormattedInput({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<div
|
||||
className={cn('relative', className)}
|
||||
data-chip-modal-enter-owner={showEnvVars ? '' : undefined}
|
||||
>
|
||||
<ChipInput
|
||||
ref={ref}
|
||||
placeholder={placeholder}
|
||||
|
||||
+1
@@ -88,6 +88,7 @@ export function TransferOwnershipDialog({
|
||||
onOpenChange={handleClose}
|
||||
srTitle='Leave organization'
|
||||
title='Leave organization'
|
||||
defaultAction='none'
|
||||
confirm={{
|
||||
label: 'Transfer & leave',
|
||||
onClick: handleConfirm,
|
||||
|
||||
@@ -1793,6 +1793,7 @@ export function Table({
|
||||
? `Delete ${deletingColumns.length} Columns`
|
||||
: 'Delete Column'
|
||||
}
|
||||
defaultAction='dismiss'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
deletingColumns && deletingColumns.length > 1
|
||||
@@ -1823,6 +1824,7 @@ export function Table({
|
||||
onOpenChange={setShowDeleteTableConfirm}
|
||||
srTitle='Delete Table'
|
||||
title='Delete Table'
|
||||
defaultAction='dismiss'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
{ text: tableData?.name ?? 'this table', bold: true },
|
||||
|
||||
+1
@@ -500,6 +500,7 @@ export function ImportCsvDialog({
|
||||
<ChipModalFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
cancelDisabled={importMutation.isPending}
|
||||
defaultAction={mode === 'replace' ? 'none' : 'primary'}
|
||||
primaryAction={{
|
||||
label: importMutation.isPending
|
||||
? mode === 'replace'
|
||||
|
||||
@@ -1441,6 +1441,7 @@ export function Tables() {
|
||||
}}
|
||||
srTitle='Delete Table'
|
||||
title='Delete Table'
|
||||
defaultAction='dismiss'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
{ text: activeTable?.name ?? 'this table', bold: true },
|
||||
|
||||
+25
-28
@@ -1,19 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useId, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
ChipConfirmModal,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalHeader,
|
||||
cn,
|
||||
Expand,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@sim/emcn'
|
||||
@@ -62,6 +60,7 @@ export function GeneralDeploy({
|
||||
onLoadDeploymentComplete,
|
||||
onLoadDeploymentBlocked,
|
||||
}: GeneralDeployProps) {
|
||||
const expandedPreviewDescriptionId = useId()
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null)
|
||||
const [showActiveDespiteSelection, setShowActiveDespiteSelection] = useState(false)
|
||||
const previewMode: PreviewMode =
|
||||
@@ -138,13 +137,6 @@ export function GeneralDeploy({
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setShowLoadDialog(false)
|
||||
setVersionToLoad(null)
|
||||
setShowPromoteDialog(false)
|
||||
setVersionToPromote(null)
|
||||
}, [workflowId])
|
||||
|
||||
const confirmPromoteToLive = async () => {
|
||||
if (!versionToPromote || isPromotingVersion) return
|
||||
const target = versionToPromote
|
||||
@@ -336,21 +328,26 @@ export function GeneralDeploy({
|
||||
/>
|
||||
|
||||
{workflowToShow && (
|
||||
<Modal open={showExpandedPreview} onOpenChange={setShowExpandedPreview}>
|
||||
<ModalContent size='full' className='flex h-[90vh] flex-col'>
|
||||
<ModalHeader>
|
||||
{previewMode === 'selected' && selectedVersionInfo
|
||||
? formatVersionLabel(selectedVersionInfo.version, selectedVersionInfo.name)
|
||||
: 'Live Workflow'}
|
||||
</ModalHeader>
|
||||
<ModalBody className='!p-0 min-h-0 flex-1 overflow-hidden'>
|
||||
<ModalDescription className='sr-only'>
|
||||
Visual preview of the selected workflow version.
|
||||
</ModalDescription>
|
||||
<Preview workflowState={workflowToShow} autoSelectLeftmost />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<ChipModal
|
||||
open={showExpandedPreview}
|
||||
onOpenChange={setShowExpandedPreview}
|
||||
srTitle='Workflow preview'
|
||||
aria-describedby={expandedPreviewDescriptionId}
|
||||
size='full'
|
||||
className='h-[90vh] [&>div]:h-full'
|
||||
>
|
||||
<ChipModalHeader onClose={() => setShowExpandedPreview(false)}>
|
||||
{previewMode === 'selected' && selectedVersionInfo
|
||||
? formatVersionLabel(selectedVersionInfo.version, selectedVersionInfo.name)
|
||||
: 'Live Workflow'}
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody fullBleed>
|
||||
<p id={expandedPreviewDescriptionId} className='sr-only'>
|
||||
Visual preview of the selected workflow version.
|
||||
</p>
|
||||
<Preview workflowState={workflowToShow} autoSelectLeftmost />
|
||||
</ChipModalBody>
|
||||
</ChipModal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
+249
-266
@@ -1,23 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Chip,
|
||||
ChipConfirmModal,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
ChipModalTabs,
|
||||
chipContentIconClass,
|
||||
cn,
|
||||
Loader,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTabs,
|
||||
ModalTabsContent,
|
||||
ModalTabsList,
|
||||
ModalTabsTrigger,
|
||||
Tooltip,
|
||||
toast,
|
||||
} from '@sim/emcn'
|
||||
@@ -98,6 +92,7 @@ export function DeployModal({
|
||||
deployment,
|
||||
deployReadiness,
|
||||
}: DeployModalProps) {
|
||||
const descriptionId = useId()
|
||||
const {
|
||||
status: deploymentStatus,
|
||||
isDeployed: isDeployedProp,
|
||||
@@ -140,6 +135,12 @@ export function DeployModal({
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig()
|
||||
const visibleTabs = [
|
||||
{ value: 'general', label: 'General' },
|
||||
...(!permissionConfig.hideDeployApi ? [{ value: 'api', label: 'API' }] : []),
|
||||
...(!permissionConfig.hideDeployMcp ? [{ value: 'mcp', label: 'MCP' }] : []),
|
||||
...(!permissionConfig.hideDeployChatbot ? [{ value: 'chat', label: 'Chat' }] : []),
|
||||
]
|
||||
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(
|
||||
workflowWorkspaceId || '',
|
||||
'combined',
|
||||
@@ -224,9 +225,10 @@ export function DeployModal({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
deployActionIdRef.current += 1
|
||||
setIsFinalizingDeploy(false)
|
||||
setUndeployTargetWorkflowId(null)
|
||||
return () => {
|
||||
deployActionIdRef.current += 1
|
||||
if (workflowId) releaseDeployAction(workflowId)
|
||||
}
|
||||
}, [workflowId])
|
||||
|
||||
const getApiKeyLabel = (value?: string | null) => {
|
||||
@@ -281,33 +283,33 @@ export function DeployModal({
|
||||
selectedStreamingOutputsRef.current = selectedStreamingOutputs
|
||||
|
||||
useEffect(() => {
|
||||
if (open && workflowId) {
|
||||
setActiveTab('general')
|
||||
setDeployError(null)
|
||||
setChatSuccess(false)
|
||||
if (!open || !workflowId) return
|
||||
|
||||
const currentOutputs = selectedStreamingOutputsRef.current
|
||||
if (currentOutputs.length > 0) {
|
||||
const blocks = Object.values(useWorkflowStore.getState().blocks)
|
||||
const validOutputs = currentOutputs.filter((outputId) => {
|
||||
if (startsWithUuid(outputId)) {
|
||||
const underscoreIndex = outputId.indexOf('_')
|
||||
if (underscoreIndex === -1) return false
|
||||
const blockId = outputId.substring(0, underscoreIndex)
|
||||
return blocks.some((b) => b.id === blockId)
|
||||
}
|
||||
const parts = outputId.split('.')
|
||||
if (parts.length >= 2) {
|
||||
const blockName = parts[0]
|
||||
return blocks.some((b) => b.name && normalizeName(b.name) === blockName.toLowerCase())
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (validOutputs.length !== currentOutputs.length) {
|
||||
setSelectedStreamingOutputs(validOutputs)
|
||||
setDeployError(null)
|
||||
setChatSuccess(false)
|
||||
|
||||
const currentOutputs = selectedStreamingOutputsRef.current
|
||||
if (currentOutputs.length > 0) {
|
||||
const blocks = Object.values(useWorkflowStore.getState().blocks)
|
||||
const validOutputs = currentOutputs.filter((outputId) => {
|
||||
if (startsWithUuid(outputId)) {
|
||||
const underscoreIndex = outputId.indexOf('_')
|
||||
if (underscoreIndex === -1) return false
|
||||
const blockId = outputId.substring(0, underscoreIndex)
|
||||
return blocks.some((b) => b.id === blockId)
|
||||
}
|
||||
const parts = outputId.split('.')
|
||||
if (parts.length >= 2) {
|
||||
const blockName = parts[0]
|
||||
return blocks.some((b) => b.name && normalizeName(b.name) === blockName.toLowerCase())
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (validOutputs.length !== currentOutputs.length) {
|
||||
setSelectedStreamingOutputs(validOutputs)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (chatSuccessTimeoutRef.current) {
|
||||
clearTimeout(chatSuccessTimeoutRef.current)
|
||||
@@ -488,7 +490,14 @@ export function DeployModal({
|
||||
deployActionIdRef.current += 1
|
||||
setIsFinalizingDeploy(false)
|
||||
if (workflowId) releaseDeployAction(workflowId)
|
||||
setActiveTab('general')
|
||||
setChatSubmitting(false)
|
||||
setIsChatFormValid(false)
|
||||
setChatSuccess(false)
|
||||
setMcpToolSubmitting(false)
|
||||
setMcpToolCanSave(false)
|
||||
setMcpToolSaveDisabledReason(null)
|
||||
setMcpActiveServerId(null)
|
||||
setDeployError(null)
|
||||
onOpenChange(false)
|
||||
}
|
||||
@@ -509,11 +518,6 @@ export function DeployModal({
|
||||
await refetchChatInfo()
|
||||
}
|
||||
|
||||
const handleChatFormSubmit = () => {
|
||||
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
|
||||
form?.requestSubmit()
|
||||
}
|
||||
|
||||
const handleChatDelete = () => {
|
||||
const form = document.getElementById('chat-deploy-form') as HTMLFormElement
|
||||
if (form) {
|
||||
@@ -524,213 +528,187 @@ export function DeployModal({
|
||||
}
|
||||
}
|
||||
|
||||
const handleMcpToolFormSubmit = () => {
|
||||
const form = document.getElementById('mcp-deploy-form') as HTMLFormElement
|
||||
form?.requestSubmit()
|
||||
}
|
||||
|
||||
const isSubmitting = deployMutation.isPending || isFinalizingDeploy
|
||||
const isUndeploying = undeployMutation.isPending
|
||||
const chatActionLabel = chatSuccess
|
||||
? chatExists
|
||||
? 'Updated'
|
||||
: 'Launched'
|
||||
: chatSubmitting
|
||||
? chatExists
|
||||
? 'Updating...'
|
||||
: 'Launching...'
|
||||
: chatExists
|
||||
? 'Update'
|
||||
: 'Launch Chat'
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleCloseModal}>
|
||||
<ModalContent size='lg' className='h-[76vh]'>
|
||||
<ModalHeader>Workflow Deployment</ModalHeader>
|
||||
<ChipModal
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) handleCloseModal()
|
||||
}}
|
||||
srTitle='Workflow Deployment'
|
||||
aria-describedby={descriptionId}
|
||||
size='lg'
|
||||
className='h-[76vh] [&>div]:h-full'
|
||||
>
|
||||
<ChipModalHeader onClose={handleCloseModal}>Workflow Deployment</ChipModalHeader>
|
||||
|
||||
<ModalTabs
|
||||
<ChipModalBody>
|
||||
<p id={descriptionId} className='sr-only'>
|
||||
Configure and manage workflow deployment settings including API, MCP, and chat options.
|
||||
</p>
|
||||
<ChipModalTabs
|
||||
tabs={visibleTabs}
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as TabView)}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
>
|
||||
<ModalTabsList activeValue={activeTab}>
|
||||
<ModalTabsTrigger value='general'>General</ModalTabsTrigger>
|
||||
{!permissionConfig.hideDeployApi && (
|
||||
<ModalTabsTrigger value='api'>API</ModalTabsTrigger>
|
||||
)}
|
||||
{!permissionConfig.hideDeployMcp && (
|
||||
<ModalTabsTrigger value='mcp'>MCP</ModalTabsTrigger>
|
||||
)}
|
||||
{!permissionConfig.hideDeployChatbot && (
|
||||
<ModalTabsTrigger value='chat'>Chat</ModalTabsTrigger>
|
||||
)}
|
||||
</ModalTabsList>
|
||||
|
||||
<ModalBody className='min-h-0 flex-1'>
|
||||
<ModalDescription className='sr-only'>
|
||||
Configure and manage workflow deployment settings including API, MCP, and chat
|
||||
options.
|
||||
</ModalDescription>
|
||||
{deployError && (
|
||||
<div className='mb-3' role='alert'>
|
||||
<Badge variant='red' size='lg' dot className='max-w-full truncate'>
|
||||
{deployError}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<ModalTabsContent value='general'>
|
||||
<GeneralDeploy
|
||||
workflowId={workflowId}
|
||||
deployedState={deployedState}
|
||||
isLoadingDeployedState={isLoadingDeployedState}
|
||||
isAwaitingSnapshot={isAwaitingSnapshot}
|
||||
versions={versions}
|
||||
versionsLoading={versionsLoading}
|
||||
isPromotingVersion={isActivatingVersion || activateVersionMutation.isPending}
|
||||
deployReadiness={deployReadiness}
|
||||
onPromoteToLive={handlePromoteToLive}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
onLoadDeploymentBlocked={setDeployError}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
|
||||
<ModalTabsContent value='api' className='h-full'>
|
||||
<ApiDeploy
|
||||
workflowId={workflowId}
|
||||
deploymentInfo={deploymentInfo}
|
||||
isLoading={isLoadingDeploymentInfo}
|
||||
needsRedeployment={needsRedeployment}
|
||||
getInputFormatExample={getInputFormatExample}
|
||||
selectedStreamingOutputs={selectedStreamingOutputs}
|
||||
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
|
||||
<ModalTabsContent value='chat'>
|
||||
<ChatDeploy
|
||||
workflowId={workflowId || ''}
|
||||
deploymentInfo={deploymentInfo}
|
||||
existingChat={existingChat as ExistingChat | null}
|
||||
isLoadingChat={isLoadingChat}
|
||||
onRefetchChat={handleRefetchChat}
|
||||
chatSubmitting={chatSubmitting}
|
||||
setChatSubmitting={setChatSubmitting}
|
||||
canRevealPassword={userPermissions.canAdmin}
|
||||
onValidationChange={setIsChatFormValid}
|
||||
onDeploymentComplete={handleCloseModal}
|
||||
onDeployed={handleChatDeployed}
|
||||
onVersionActivated={() => {}}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
|
||||
<ModalTabsContent value='mcp' className='h-full'>
|
||||
{workflowId && (
|
||||
<McpDeploy
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowMetadata?.name || 'Workflow'}
|
||||
workflowDescription={workflowMetadata?.description}
|
||||
isDeployed={isDeployed}
|
||||
deployedState={deployedState}
|
||||
isLoadingDeployedState={isLoadingDeployedState}
|
||||
onSubmittingChange={setMcpToolSubmitting}
|
||||
onCanSaveChange={setMcpToolCanSave}
|
||||
onSaveDisabledReasonChange={setMcpToolSaveDisabledReason}
|
||||
onActiveServerChange={setMcpActiveServerId}
|
||||
/>
|
||||
)}
|
||||
</ModalTabsContent>
|
||||
</ModalBody>
|
||||
</ModalTabs>
|
||||
|
||||
onChange={(value) => {
|
||||
if (isDeployModalTab(value)) setActiveTab(value)
|
||||
}}
|
||||
aria-label='Deployment settings'
|
||||
/>
|
||||
{deployError && (
|
||||
<div role='alert'>
|
||||
<Badge variant='red' size='lg' dot className='max-w-full truncate'>
|
||||
{deployError}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'general' && (
|
||||
<GeneralFooter
|
||||
isDeployed={isDeployed}
|
||||
needsRedeployment={needsRedeployment}
|
||||
isSubmitting={isSubmitting}
|
||||
isUndeploying={isUndeploying}
|
||||
<GeneralDeploy
|
||||
workflowId={workflowId}
|
||||
deployedState={deployedState}
|
||||
isLoadingDeployedState={isLoadingDeployedState}
|
||||
isAwaitingSnapshot={isAwaitingSnapshot}
|
||||
versions={versions}
|
||||
versionsLoading={versionsLoading}
|
||||
isPromotingVersion={isActivatingVersion || activateVersionMutation.isPending}
|
||||
deployReadiness={deployReadiness}
|
||||
isDeploymentSettling={isDeploymentSettling}
|
||||
attemptStatus={deploymentAttemptStatus}
|
||||
attemptErrorMessage={attemptErrorMessage}
|
||||
onDeploy={onDeploy}
|
||||
onRedeploy={handleRedeploy}
|
||||
onUndeploy={() => {
|
||||
if (workflowId) setUndeployTargetWorkflowId(workflowId)
|
||||
}}
|
||||
onPromoteToLive={handlePromoteToLive}
|
||||
onLoadDeploymentComplete={handleCloseModal}
|
||||
onLoadDeploymentBlocked={setDeployError}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'api' && (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Chip onClick={() => setIsApiInfoModalOpen(true)}>Edit API Info</Chip>
|
||||
<Chip
|
||||
variant='primary'
|
||||
onClick={() => setIsCreateKeyModalOpen(true)}
|
||||
disabled={createButtonDisabled}
|
||||
>
|
||||
Generate API Key
|
||||
</Chip>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
<ApiDeploy
|
||||
workflowId={workflowId}
|
||||
deploymentInfo={deploymentInfo}
|
||||
isLoading={isLoadingDeploymentInfo}
|
||||
needsRedeployment={needsRedeployment}
|
||||
getInputFormatExample={getInputFormatExample}
|
||||
selectedStreamingOutputs={selectedStreamingOutputs}
|
||||
onSelectedStreamingOutputsChange={setSelectedStreamingOutputs}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'chat' && (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
{chatExists && (
|
||||
<Chip type='button' onClick={handleChatDelete} disabled={chatSubmitting}>
|
||||
Delete
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
type='button'
|
||||
variant='primary'
|
||||
onClick={handleChatFormSubmit}
|
||||
disabled={chatSubmitting || !isChatFormValid}
|
||||
>
|
||||
{chatSuccess
|
||||
? chatExists
|
||||
? 'Updated'
|
||||
: 'Launched'
|
||||
: chatSubmitting
|
||||
? chatExists
|
||||
? 'Updating...'
|
||||
: 'Launching...'
|
||||
: chatExists
|
||||
? 'Update'
|
||||
: 'Launch Chat'}
|
||||
</Chip>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
<ChatDeploy
|
||||
workflowId={workflowId || ''}
|
||||
deploymentInfo={deploymentInfo}
|
||||
existingChat={existingChat as ExistingChat | null}
|
||||
isLoadingChat={isLoadingChat}
|
||||
onRefetchChat={handleRefetchChat}
|
||||
chatSubmitting={chatSubmitting}
|
||||
setChatSubmitting={setChatSubmitting}
|
||||
canRevealPassword={userPermissions.canAdmin}
|
||||
onValidationChange={setIsChatFormValid}
|
||||
onDeploymentComplete={handleCloseModal}
|
||||
onDeployed={handleChatDeployed}
|
||||
onVersionActivated={() => {}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'mcp' && isDeployed && hasMcpServers && (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Chip
|
||||
type='button'
|
||||
onClick={() =>
|
||||
navigateToSettings({
|
||||
section: 'workflow-mcp-servers',
|
||||
mcpServerId: mcpActiveServerId ?? undefined,
|
||||
})
|
||||
{activeTab === 'mcp' && workflowId && (
|
||||
<McpDeploy
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowMetadata?.name || 'Workflow'}
|
||||
workflowDescription={workflowMetadata?.description}
|
||||
isDeployed={isDeployed}
|
||||
deployedState={deployedState}
|
||||
isLoadingDeployedState={isLoadingDeployedState}
|
||||
onSubmittingChange={setMcpToolSubmitting}
|
||||
onCanSaveChange={setMcpToolCanSave}
|
||||
onSaveDisabledReasonChange={setMcpToolSaveDisabledReason}
|
||||
onActiveServerChange={setMcpActiveServerId}
|
||||
/>
|
||||
)}
|
||||
</ChipModalBody>
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<GeneralFooter
|
||||
isDeployed={isDeployed}
|
||||
needsRedeployment={needsRedeployment}
|
||||
isSubmitting={isSubmitting}
|
||||
isUndeploying={isUndeploying}
|
||||
deployReadiness={deployReadiness}
|
||||
isDeploymentSettling={isDeploymentSettling}
|
||||
attemptStatus={deploymentAttemptStatus}
|
||||
attemptErrorMessage={attemptErrorMessage}
|
||||
onDeploy={onDeploy}
|
||||
onRedeploy={handleRedeploy}
|
||||
onUndeploy={() => {
|
||||
if (workflowId) setUndeployTargetWorkflowId(workflowId)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'api' && (
|
||||
<ChipModalFooter
|
||||
onCancel={handleCloseModal}
|
||||
hideCancel
|
||||
defaultAction='none'
|
||||
primaryAdjacentAction={{
|
||||
label: 'Edit API Info',
|
||||
onClick: () => setIsApiInfoModalOpen(true),
|
||||
}}
|
||||
primaryAction={{
|
||||
label: 'Generate API Key',
|
||||
onClick: () => setIsCreateKeyModalOpen(true),
|
||||
disabled: createButtonDisabled,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'chat' && (
|
||||
<ChipModalFooter
|
||||
onCancel={handleCloseModal}
|
||||
hideCancel
|
||||
primaryAdjacentAction={
|
||||
chatExists
|
||||
? {
|
||||
label: 'Delete',
|
||||
onClick: handleChatDelete,
|
||||
disabled: chatSubmitting,
|
||||
}
|
||||
>
|
||||
Manage
|
||||
</Chip>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span>
|
||||
<Chip
|
||||
type='button'
|
||||
variant='primary'
|
||||
onClick={handleMcpToolFormSubmit}
|
||||
disabled={mcpToolSubmitting || !mcpToolCanSave}
|
||||
>
|
||||
{mcpToolSubmitting ? 'Saving...' : 'Save Tool'}
|
||||
</Chip>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
{mcpToolSaveDisabledReason && (
|
||||
<Tooltip.Content>{mcpToolSaveDisabledReason}</Tooltip.Content>
|
||||
)}
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
: undefined
|
||||
}
|
||||
primaryAction={{
|
||||
label: chatActionLabel,
|
||||
type: 'submit',
|
||||
form: 'chat-deploy-form',
|
||||
disabled: chatSubmitting || !isChatFormValid,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'mcp' && isDeployed && hasMcpServers && (
|
||||
<ChipModalFooter
|
||||
onCancel={handleCloseModal}
|
||||
hideCancel
|
||||
primaryAdjacentAction={{
|
||||
label: 'Manage',
|
||||
onClick: () =>
|
||||
navigateToSettings({
|
||||
section: 'workflow-mcp-servers',
|
||||
mcpServerId: mcpActiveServerId ?? undefined,
|
||||
}),
|
||||
}}
|
||||
primaryAction={{
|
||||
label: mcpToolSubmitting ? 'Saving...' : 'Save Tool',
|
||||
type: 'submit',
|
||||
form: 'mcp-deploy-form',
|
||||
disabled: mcpToolSubmitting || !mcpToolCanSave,
|
||||
disabledTooltip: mcpToolSaveDisabledReason ?? undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ChipModal>
|
||||
|
||||
<ChipConfirmModal
|
||||
open={Boolean(undeployTargetWorkflowId)}
|
||||
@@ -909,40 +887,45 @@ function GeneralFooter({
|
||||
|
||||
if (!isDeployed) {
|
||||
return (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{status}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Chip
|
||||
variant='primary'
|
||||
onClick={onDeploy}
|
||||
disabled={isDeployBlocked}
|
||||
leftAdornment={deployLoader}
|
||||
>
|
||||
Deploy
|
||||
</Chip>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
<ChipModalFooter
|
||||
hideCancel
|
||||
leadingContent={status}
|
||||
primaryAction={{
|
||||
label: 'Deploy',
|
||||
onClick: onDeploy,
|
||||
disabled: isDeployBlocked,
|
||||
leftAdornment: deployLoader,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
{status}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Chip onClick={onUndeploy} disabled={isUndeploying || isSubmitting}>
|
||||
{isUndeploying ? 'Undeploying...' : 'Undeploy'}
|
||||
</Chip>
|
||||
{(needsRedeployment || isDeploymentSettling) && (
|
||||
<Chip
|
||||
variant='primary'
|
||||
onClick={onRedeploy}
|
||||
disabled={isDeployBlocked}
|
||||
leftAdornment={deployLoader}
|
||||
>
|
||||
Update
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
const hasUpdateAction = needsRedeployment || isDeploymentSettling
|
||||
|
||||
const undeployAction = {
|
||||
label: isUndeploying ? 'Undeploying...' : 'Undeploy',
|
||||
onClick: onUndeploy,
|
||||
disabled: isUndeploying || isSubmitting,
|
||||
}
|
||||
|
||||
return hasUpdateAction ? (
|
||||
<ChipModalFooter
|
||||
hideCancel
|
||||
leadingContent={status}
|
||||
primaryAdjacentAction={undeployAction}
|
||||
primaryAction={{
|
||||
label: 'Update',
|
||||
onClick: onRedeploy,
|
||||
disabled: isDeployBlocked,
|
||||
leftAdornment: deployLoader,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ChipModalFooter
|
||||
hideCancel
|
||||
defaultAction='none'
|
||||
leadingContent={status}
|
||||
primaryAdjacentAction={undeployAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -188,6 +188,7 @@ export function Deploy({ activeWorkflowId, userPermissions, disabled = false }:
|
||||
</Tooltip.Root>
|
||||
|
||||
<DeployModal
|
||||
key={activeWorkflowId ?? 'no-workflow'}
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
workflowId={activeWorkflowId}
|
||||
|
||||
@@ -990,6 +990,7 @@ export const Panel = memo(function Panel() {
|
||||
onOpenChange={setIsDeleteModalOpen}
|
||||
srTitle='Delete Workflow'
|
||||
title='Delete Workflow'
|
||||
defaultAction='dismiss'
|
||||
text={[
|
||||
'Are you sure you want to delete ',
|
||||
{ text: currentWorkflow?.name ?? 'this workflow', bold: true },
|
||||
|
||||
+6
@@ -68,6 +68,11 @@ export function DeleteModal({
|
||||
const isWorkspace = itemType === 'workspace'
|
||||
const workspaceName = isWorkspace && displayNames.length > 0 ? displayNames[0] : ''
|
||||
const isConfirmed = !isWorkspace || confirmationText === workspaceName
|
||||
const defaultAction = isWorkspace
|
||||
? 'none'
|
||||
: isSingle && itemType === 'task'
|
||||
? 'confirm'
|
||||
: 'dismiss'
|
||||
|
||||
let title = ''
|
||||
if (itemType === 'workflow') {
|
||||
@@ -200,6 +205,7 @@ export function DeleteModal({
|
||||
onOpenChange={handleClose}
|
||||
srTitle={title}
|
||||
title={title}
|
||||
defaultAction={defaultAction}
|
||||
text={[
|
||||
...buildDescriptionSegments(),
|
||||
' ',
|
||||
|
||||
@@ -268,7 +268,7 @@ function AddMembersModal({
|
||||
All organization members are already in this group.
|
||||
</p>
|
||||
) : (
|
||||
<ChipModalField type='custom' title='Members'>
|
||||
<ChipModalField type='custom' title='Members' submitOnEnter={false}>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ChipInput
|
||||
|
||||
@@ -266,6 +266,7 @@ export function CredentialGroupDetails({
|
||||
title={`Remove ${
|
||||
removingProvider ? getCredentialGroupProviderService(removingProvider).name : 'account'
|
||||
}`}
|
||||
defaultAction='confirm'
|
||||
text='People will no longer be asked to connect this account. Existing credentials are retained but will no longer be returned by this group.'
|
||||
dismissLabel='Cancel'
|
||||
confirm={{
|
||||
|
||||
@@ -755,6 +755,7 @@ export function CustomBlockDetail({ blockId, workspaceId, onBack }: CustomBlockD
|
||||
onOpenChange={() => setShowDelete(false)}
|
||||
srTitle='Delete custom block'
|
||||
title='Delete custom block'
|
||||
defaultAction='none'
|
||||
text={[
|
||||
'Delete ',
|
||||
{ text: existing?.name ?? 'this block', bold: true },
|
||||
|
||||
@@ -179,12 +179,6 @@ export function ForkWorkspaceModal({
|
||||
<ChipInput
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder='Workspace name'
|
||||
maxLength={100}
|
||||
autoComplete='off'
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { act, type FormEvent, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ChipInput } from '../chip-input/chip-input'
|
||||
import { Modal, ModalContent, ModalHeader } from '../modal/modal'
|
||||
import {
|
||||
ChipConfirmModal,
|
||||
@@ -34,6 +35,7 @@ afterEach(() => {
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** The dialog panel Radix renders, which owns the Escape/outside-click handlers. */
|
||||
@@ -62,11 +64,49 @@ function closeButton(): HTMLButtonElement {
|
||||
function pressEscape() {
|
||||
act(() => {
|
||||
dialog().dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function pressEnter(target: HTMLElement, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
})
|
||||
act(() => target.dispatchEvent(event))
|
||||
return event
|
||||
}
|
||||
|
||||
function makeElementsVisible(): void {
|
||||
const rect = {
|
||||
bottom: 1,
|
||||
height: 1,
|
||||
left: 0,
|
||||
right: 1,
|
||||
top: 0,
|
||||
width: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} satisfies DOMRect
|
||||
const rects = {
|
||||
0: rect,
|
||||
length: 1,
|
||||
item: (index: number) => (index === 0 ? rect : null),
|
||||
[Symbol.iterator]: function* () {
|
||||
yield rect
|
||||
},
|
||||
} as DOMRectList
|
||||
vi.spyOn(Element.prototype, 'getClientRects').mockReturnValue(rects)
|
||||
}
|
||||
|
||||
function Harness({
|
||||
onOpenChange,
|
||||
dismissDisabled,
|
||||
@@ -195,7 +235,12 @@ describe('ChipConfirmModal pending', () => {
|
||||
onOpenChange={onOpenChange}
|
||||
title='Delete key'
|
||||
text='This cannot be undone.'
|
||||
confirm={{ label: 'Delete', onClick: () => {}, pending: true, pendingLabel: 'Deleting...' }}
|
||||
confirm={{
|
||||
label: 'Delete',
|
||||
onClick: () => {},
|
||||
pending: true,
|
||||
pendingLabel: 'Deleting...',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -225,6 +270,416 @@ describe('ChipConfirmModal pending', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChipModal default actions', () => {
|
||||
beforeEach(makeElementsVisible)
|
||||
|
||||
it('fails safe to the dismiss decision in a confirmation', () => {
|
||||
mount(
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Delete key'
|
||||
confirm={{ label: 'Delete', onClick: () => {} }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.activeElement).toBe(buttonByText('Cancel'))
|
||||
})
|
||||
|
||||
it('focuses an explicitly declared confirm decision', () => {
|
||||
mount(
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Restore archive'
|
||||
defaultAction='confirm'
|
||||
confirm={{ label: 'Restore', onClick: () => {}, variant: 'primary' }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.activeElement).toBe(buttonByText('Restore'))
|
||||
})
|
||||
|
||||
it('focuses the dialog instead of arming a decision when the policy is none', () => {
|
||||
mount(
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Delete account'
|
||||
defaultAction='none'
|
||||
confirm={{ label: 'Delete account', onClick: () => {} }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.activeElement).toBe(dialog())
|
||||
})
|
||||
|
||||
it('treats the regular footer primary as the default action', () => {
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Create project'>
|
||||
<ChipModalHeader onClose={() => {}}>Create project</ChipModalHeader>
|
||||
<ChipModalFooter
|
||||
onCancel={() => {}}
|
||||
primaryAction={{ label: 'Create', onClick: () => {} }}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
expect(document.activeElement).toBe(buttonByText('Create'))
|
||||
})
|
||||
|
||||
it('supports a form-associated primary action without product-level DOM markers', () => {
|
||||
const onSubmit = vi.fn((event: FormEvent) => event.preventDefault())
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Deploy workflow'>
|
||||
<ChipModalHeader onClose={() => {}}>Deploy workflow</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<form id='deploy-form' onSubmit={onSubmit}>
|
||||
<ChipInput aria-label='Name' value='Canary' onChange={() => {}} />
|
||||
</form>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
hideCancel
|
||||
primaryAction={{ label: 'Deploy', type: 'submit', form: 'deploy-form' }}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
const submit = buttonByText('Deploy')
|
||||
expect(submit.type).toBe('submit')
|
||||
expect(submit.getAttribute('form')).toBe('deploy-form')
|
||||
act(() => submit.click())
|
||||
expect(onSubmit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('supports a canonical footer with no primary decision', () => {
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Deployment status'>
|
||||
<ChipModalHeader onClose={() => {}}>Deployment status</ChipModalHeader>
|
||||
<ChipModalFooter hideCancel defaultAction='none' leadingContent={<span>Live</span>} />
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
expect(document.activeElement).toBe(dialog())
|
||||
expect(dialog().textContent).toContain('Live')
|
||||
})
|
||||
|
||||
it('falls back to an enabled dismiss action when the declared action is disabled', () => {
|
||||
mount(
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Delete key'
|
||||
defaultAction='confirm'
|
||||
confirm={{ label: 'Delete', onClick: () => {}, disabled: true }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.activeElement).toBe(buttonByText('Cancel'))
|
||||
})
|
||||
|
||||
it('falls back to the dialog when pending disables every decision', () => {
|
||||
mount(
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Delete key'
|
||||
defaultAction='confirm'
|
||||
confirm={{ label: 'Delete', onClick: () => {}, pending: true }}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(document.activeElement).toBe(dialog())
|
||||
})
|
||||
|
||||
it('ignores hidden policies and activates the visible default action', () => {
|
||||
const onHidden = vi.fn()
|
||||
const onVisible = vi.fn()
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Visible action'>
|
||||
<ChipModalHeader onClose={() => {}}>Visible action</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField type='input' title='Name' value='Canary' onChange={() => {}} />
|
||||
</ChipModalBody>
|
||||
<div aria-hidden='true'>
|
||||
<ChipModalFooter
|
||||
onCancel={() => {}}
|
||||
primaryAction={{ label: 'Hidden save', onClick: onHidden }}
|
||||
/>
|
||||
</div>
|
||||
<ChipModalFooter
|
||||
onCancel={() => {}}
|
||||
primaryAction={{ label: 'Visible save', onClick: onVisible }}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input')
|
||||
if (!input) throw new Error('Name input did not render')
|
||||
pressEnter(input)
|
||||
expect(onHidden).not.toHaveBeenCalled()
|
||||
expect(onVisible).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('submits a canonical single-line field through the regular footer', () => {
|
||||
const onSave = vi.fn()
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Rename project'>
|
||||
<ChipModalHeader onClose={() => {}}>Rename project</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField type='input' title='Name' value='Canary' onChange={() => {}} />
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter onCancel={() => {}} primaryAction={{ label: 'Save', onClick: onSave }} />
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input')
|
||||
if (!input) throw new Error('Name input did not render')
|
||||
expect(document.activeElement).toBe(input)
|
||||
expect(pressEnter(input).defaultPrevented).toBe(true)
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('submits a raw custom ChipInput through the modal fallback', () => {
|
||||
const onSave = vi.fn()
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Rename project'>
|
||||
<ChipModalHeader onClose={() => {}}>Rename project</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipInput aria-label='Name' value='Canary' onChange={() => {}} />
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter onCancel={() => {}} primaryAction={{ label: 'Save', onClick: onSave }} />
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('[aria-label="Name"]')
|
||||
if (!input) throw new Error('Custom input did not render')
|
||||
expect(pressEnter(input).defaultPrevented).toBe(true)
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('registers an explicit confirm decision for canonical field submission', () => {
|
||||
const onConfirm = vi.fn()
|
||||
mount(
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Rename and restore'
|
||||
defaultAction='confirm'
|
||||
confirm={{ label: 'Restore', onClick: onConfirm, variant: 'primary' }}
|
||||
>
|
||||
<ChipModalField type='input' title='Name' value='Canary' onChange={() => {}} />
|
||||
</ChipConfirmModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input')
|
||||
if (!input) throw new Error('Name input did not render')
|
||||
pressEnter(input)
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not register the confirm decision when dismissal remains the default', () => {
|
||||
const onConfirm = vi.fn()
|
||||
mount(
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Delete key'
|
||||
confirm={{ label: 'Delete', onClick: onConfirm }}
|
||||
>
|
||||
<ChipModalField type='input' title='Name' value='Canary' onChange={() => {}} />
|
||||
</ChipConfirmModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input')
|
||||
if (!input) throw new Error('Name input did not render')
|
||||
expect(pressEnter(input).defaultPrevented).toBe(false)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('yields Enter to a native form', () => {
|
||||
const onPrimary = vi.fn()
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Native form'>
|
||||
<ChipModalHeader onClose={() => {}}>Native form</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<ChipInput aria-label='Native field' value='Canary' onChange={() => {}} />
|
||||
</form>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => {}}
|
||||
primaryAction={{ label: 'Save', onClick: onPrimary }}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('[aria-label="Native field"]')
|
||||
if (!input) throw new Error('Native form input did not render')
|
||||
expect(pressEnter(input).defaultPrevented).toBe(false)
|
||||
expect(onPrimary).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('yields a canonical field to its native form', () => {
|
||||
const onPrimary = vi.fn()
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Native form'>
|
||||
<ChipModalHeader onClose={() => {}}>Native form</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<form onSubmit={(event) => event.preventDefault()}>
|
||||
<ChipModalField type='input' title='Name' value='Canary' onChange={() => {}} />
|
||||
</form>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => {}}
|
||||
primaryAction={{ label: 'Save', onClick: onPrimary }}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input')
|
||||
if (!input) throw new Error('Native form field did not render')
|
||||
expect(pressEnter(input).defaultPrevented).toBe(false)
|
||||
expect(onPrimary).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('yields Enter to multiline, tag-like and autocomplete controls', () => {
|
||||
const onPrimary = vi.fn()
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Owned Enter controls'>
|
||||
<ChipModalHeader onClose={() => {}}>Owned Enter controls</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<textarea aria-label='Notes' defaultValue='Line one' />
|
||||
<ChipModalField type='custom' title='Tag' submitOnEnter={false}>
|
||||
<ChipInput aria-label='Tag' value='canary' onChange={() => {}} />
|
||||
</ChipModalField>
|
||||
<ChipInput
|
||||
aria-label='Autocomplete'
|
||||
aria-autocomplete='list'
|
||||
value='canary'
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => {}}
|
||||
primaryAction={{ label: 'Save', onClick: onPrimary }}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
for (const label of ['Notes', 'Tag', 'Autocomplete']) {
|
||||
const control = document.querySelector<HTMLElement>(`[aria-label="${label}"]`)
|
||||
if (!control) throw new Error(`${label} control did not render`)
|
||||
expect(pressEnter(control).defaultPrevented).toBe(false)
|
||||
}
|
||||
expect(onPrimary).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors submitOnEnter=false on canonical fields', () => {
|
||||
const onPrimary = vi.fn()
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Schedule runs'>
|
||||
<ChipModalHeader onClose={() => {}}>Schedule runs</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField
|
||||
type='input'
|
||||
title='Number of runs'
|
||||
value='2'
|
||||
onChange={() => {}}
|
||||
submitOnEnter={false}
|
||||
/>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => {}}
|
||||
primaryAction={{ label: 'Schedule', onClick: onPrimary }}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input')
|
||||
if (!input) throw new Error('Runs input did not render')
|
||||
expect(pressEnter(input).defaultPrevented).toBe(true)
|
||||
expect(onPrimary).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not activate a disabled confirm from a field', () => {
|
||||
const onConfirm = vi.fn()
|
||||
mount(
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Restore archive'
|
||||
defaultAction='confirm'
|
||||
confirm={{ label: 'Restore', onClick: onConfirm, disabled: true, variant: 'primary' }}
|
||||
>
|
||||
<ChipModalField type='input' title='Name' value='Canary' onChange={() => {}} />
|
||||
</ChipConfirmModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input')
|
||||
if (!input) throw new Error('Name input did not render')
|
||||
expect(pressEnter(input).defaultPrevented).toBe(false)
|
||||
expect(onConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores composing and repeated Enter events', () => {
|
||||
const onPrimary = vi.fn()
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Rename project'>
|
||||
<ChipModalHeader onClose={() => {}}>Rename project</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<ChipModalField type='input' title='Name' value='Canary' onChange={() => {}} />
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => {}}
|
||||
primaryAction={{ label: 'Save', onClick: onPrimary }}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
const input = document.querySelector<HTMLInputElement>('input')
|
||||
if (!input) throw new Error('Name input did not render')
|
||||
expect(pressEnter(input, { isComposing: true }).defaultPrevented).toBe(false)
|
||||
expect(pressEnter(input, { repeat: true }).defaultPrevented).toBe(false)
|
||||
expect(pressEnter(input, { shiftKey: true }).defaultPrevented).toBe(false)
|
||||
expect(pressEnter(input, { metaKey: true }).defaultPrevented).toBe(false)
|
||||
expect(onPrimary).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('associates confirmation copy with the dialog description', () => {
|
||||
mount(
|
||||
<ChipConfirmModal
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
title='Delete key'
|
||||
text='This immediately revokes access.'
|
||||
confirm={{ label: 'Delete', onClick: () => {} }}
|
||||
/>
|
||||
)
|
||||
|
||||
const descriptionId = dialog().getAttribute('aria-describedby')
|
||||
expect(descriptionId).toBeTruthy()
|
||||
expect(document.getElementById(descriptionId ?? '')?.textContent).toBe(
|
||||
'This immediately revokes access.'
|
||||
)
|
||||
})
|
||||
|
||||
it('owns full-bleed body chrome through a semantic prop', () => {
|
||||
mount(
|
||||
<ChipModal open onOpenChange={() => {}} srTitle='Preview'>
|
||||
<ChipModalHeader onClose={() => {}}>Preview</ChipModalHeader>
|
||||
<ChipModalBody fullBleed data-testid='preview-body'>
|
||||
Preview content
|
||||
</ChipModalBody>
|
||||
</ChipModal>
|
||||
)
|
||||
|
||||
const body = document.querySelector<HTMLElement>('[data-testid="preview-body"]')
|
||||
expect(body?.className).toContain('overflow-hidden')
|
||||
expect(body?.className).not.toContain('px-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChipModalField inputType='password'", () => {
|
||||
const MASK_CLASS = '[-webkit-text-security:disc]'
|
||||
|
||||
@@ -337,7 +792,10 @@ describe("ChipModalField inputType='password'", () => {
|
||||
// control's preventDefault that blur re-masks first, and the click then
|
||||
// toggles back to revealed — leaving the password on screen.
|
||||
act(() => {
|
||||
const press = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||
const press = new MouseEvent('mousedown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
toggle.dispatchEvent(press)
|
||||
if (!press.defaultPrevented) passwordInput().blur()
|
||||
toggle.click()
|
||||
|
||||
@@ -51,6 +51,7 @@ import { ChipInput } from '../chip-input/chip-input'
|
||||
import { ChipSwitch } from '../chip-switch/chip-switch'
|
||||
import { ChipTextarea } from '../chip-textarea/chip-textarea'
|
||||
import { Label } from '../label/label'
|
||||
import { focusFirstTextInputIn, isElementVisible } from '../modal/auto-focus'
|
||||
import { Modal, ModalContent, useModalDismissDisabled } from '../modal/modal'
|
||||
import { Tooltip } from '../tooltip/tooltip'
|
||||
|
||||
@@ -72,25 +73,121 @@ export function ChipModalSeparator({ className }: { className?: string }) {
|
||||
*/
|
||||
const CHIP_MODAL_FIELD_ERROR_CLASS = 'text-[var(--text-error)] text-caption'
|
||||
|
||||
/**
|
||||
* The modal's registered primary action, published by {@link ChipModalFooter}
|
||||
* and consumed by {@link ChipModalField} so a single-line input can submit the
|
||||
* modal on Enter without the consumer wiring `onSubmit` on every field.
|
||||
*/
|
||||
interface ChipModalSubmit {
|
||||
/** Fires the footer's primary action. */
|
||||
trigger: () => void
|
||||
/** Mirrors the primary action's disabled state so Enter never submits an invalid form. */
|
||||
disabled?: boolean
|
||||
const CHIP_MODAL_DEFAULT_ACTION_SELECTOR =
|
||||
'[data-chip-modal-default-action]:not([disabled]):not([aria-disabled="true"])'
|
||||
const CHIP_MODAL_SUBMIT_ACTION_SELECTOR =
|
||||
'[data-chip-modal-submit-action]:not([disabled]):not([aria-disabled="true"])'
|
||||
const CHIP_MODAL_DISMISS_ACTION_SELECTOR =
|
||||
'[data-chip-modal-dismiss-action]:not([disabled]):not([aria-disabled="true"])'
|
||||
const CHIP_MODAL_INPUT_TYPES_WITH_OWN_ENTER = new Set([
|
||||
'button',
|
||||
'checkbox',
|
||||
'color',
|
||||
'date',
|
||||
'datetime-local',
|
||||
'file',
|
||||
'hidden',
|
||||
'month',
|
||||
'radio',
|
||||
'range',
|
||||
'reset',
|
||||
'submit',
|
||||
'time',
|
||||
'week',
|
||||
])
|
||||
|
||||
function isPlainEnter(event: React.KeyboardEvent): boolean {
|
||||
return (
|
||||
event.key === 'Enter' &&
|
||||
!event.defaultPrevented &&
|
||||
!event.repeat &&
|
||||
!event.nativeEvent.isComposing &&
|
||||
!event.altKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.shiftKey
|
||||
)
|
||||
}
|
||||
|
||||
function findFocusableAction(content: HTMLElement, selector: string): HTMLElement | null {
|
||||
return (
|
||||
Array.from(content.querySelectorAll<HTMLElement>(selector)).find(
|
||||
(element) => isElementVisible(element) && !element.closest('[aria-hidden="true"], [inert]')
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function findVisibleDefaultPolicy(content: HTMLElement): string | null {
|
||||
const shell = Array.from(
|
||||
content.querySelectorAll<HTMLElement>('[data-chip-modal-default-policy]')
|
||||
).find(
|
||||
(element) => isElementVisible(element) && !element.closest('[aria-hidden="true"], [inert]')
|
||||
)
|
||||
return shell?.getAttribute('data-chip-modal-default-policy') ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries a mutable handle to the modal's primary action down to its fields.
|
||||
* A ref (not state) so the footer can keep it current without re-rendering the
|
||||
* body, and fields read it at Enter-time rather than at render-time.
|
||||
* Moves initial focus according to the modal's declared default-action policy.
|
||||
* Editable text controls remain first because typing is the natural first step
|
||||
* in a form. Otherwise the declared action receives focus, so native button
|
||||
* keyboard behavior owns Enter while that button is focused. A disabled default
|
||||
* falls back to the safe dismiss action; a `none` policy focuses the dialog
|
||||
* itself so no button is accidentally armed.
|
||||
*/
|
||||
const ChipModalSubmitContext =
|
||||
React.createContext<React.MutableRefObject<ChipModalSubmit | null> | null>(null)
|
||||
function focusChipModalDefaultAction(event: Event): void {
|
||||
const content = event.currentTarget as HTMLElement | null
|
||||
if (!content) return
|
||||
if (focusFirstTextInputIn(content)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const policy = findVisibleDefaultPolicy(content)
|
||||
const target =
|
||||
policy === 'none'
|
||||
? content
|
||||
: (findFocusableAction(content, CHIP_MODAL_DEFAULT_ACTION_SELECTOR) ??
|
||||
findFocusableAction(content, CHIP_MODAL_DISMISS_ACTION_SELECTOR) ??
|
||||
content)
|
||||
|
||||
event.preventDefault()
|
||||
target.focus()
|
||||
if (document.activeElement !== target) content.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies implicit submission for single-line custom inputs that are not a
|
||||
* {@link ChipModalField}. Canonical fields handle Enter at the control so they
|
||||
* can support `onSubmit` and `submitOnEnter`; this content-level fallback is
|
||||
* intentionally narrow and yields to native forms and controls that own Enter
|
||||
* (tag inputs, comboboxes, buttons, textareas, and rich editors).
|
||||
*/
|
||||
function handleChipModalEnter(event: React.KeyboardEvent<HTMLDivElement>): void {
|
||||
if (!isPlainEnter(event)) return
|
||||
|
||||
const target = event.target
|
||||
if (!(target instanceof HTMLInputElement) || !event.currentTarget.contains(target)) return
|
||||
if (
|
||||
target.form ||
|
||||
target.disabled ||
|
||||
target.readOnly ||
|
||||
CHIP_MODAL_INPUT_TYPES_WITH_OWN_ENTER.has(target.type) ||
|
||||
target.hasAttribute('list') ||
|
||||
target.hasAttribute('aria-autocomplete') ||
|
||||
target.hasAttribute('aria-haspopup') ||
|
||||
target.closest(
|
||||
'[data-chip-modal-enter-owner], [role="combobox"], [role="listbox"], [role="menu"]'
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const action = findFocusableAction(event.currentTarget, CHIP_MODAL_SUBMIT_ACTION_SELECTOR)
|
||||
if (!action) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
action.click()
|
||||
}
|
||||
|
||||
export interface ChipModalProps {
|
||||
/** Controlled open state. */
|
||||
@@ -99,6 +196,8 @@ export interface ChipModalProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Screen-reader title for the underlying dialog. */
|
||||
srTitle?: string
|
||||
/** ID of concise body copy that describes the dialog's purpose. */
|
||||
'aria-describedby'?: string
|
||||
/**
|
||||
* Panel width preset. Matches the underlying `Modal` widths exactly:
|
||||
* `sm` 440 · `md` 500 · `lg` 600 · `xl` 800 · `full` 1200 (px max, `w-[90vw]`
|
||||
@@ -134,31 +233,32 @@ function ChipModal({
|
||||
className,
|
||||
dismissDisabled = false,
|
||||
children,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
}: ChipModalProps) {
|
||||
const submitRef = React.useRef<ChipModalSubmit | null>(null)
|
||||
return (
|
||||
<ChipModalSubmitContext.Provider value={submitRef}>
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
bare
|
||||
showClose={false}
|
||||
srTitle={srTitle}
|
||||
size={size}
|
||||
dismissDisabled={dismissDisabled}
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
bare
|
||||
showClose={false}
|
||||
srTitle={srTitle}
|
||||
size={size}
|
||||
dismissDisabled={dismissDisabled}
|
||||
onOpenAutoFocus={focusChipModalDefaultAction}
|
||||
onKeyDown={handleChipModalEnter}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 w-full flex-col rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-[var(--shadow-overlay)] dark:bg-[var(--surface-5)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 w-full flex-col rounded-xl border border-[var(--border-muted)] bg-[var(--surface-4)] p-[3px] shadow-[var(--shadow-overlay)] dark:bg-[var(--surface-5)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex min-h-0 flex-col overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
</div>
|
||||
<div className='flex min-h-0 flex-col overflow-hidden rounded-lg border border-[var(--border-1)] bg-[var(--bg)]'>
|
||||
{children}
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</ChipModalSubmitContext.Provider>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -298,12 +398,18 @@ ChipModalTabs.displayName = 'ChipModalTabs'
|
||||
* content exceeds the viewport cap (`max-h-[84vh]` on `ModalContent`), so
|
||||
* header and footer stay pinned.
|
||||
*/
|
||||
const ChipModalBody = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
export interface ChipModalBodyProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** Removes the field gutter and scrolling chrome for one edge-to-edge surface. */
|
||||
fullBleed?: boolean
|
||||
}
|
||||
|
||||
const ChipModalBody = React.forwardRef<HTMLDivElement, ChipModalBodyProps>(
|
||||
({ className, fullBleed = false, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-2 pt-4 pb-4.5',
|
||||
'flex min-h-0 flex-1 flex-col',
|
||||
fullBleed ? 'overflow-hidden' : 'gap-4 overflow-y-auto px-2 pt-4 pb-4.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -580,6 +686,13 @@ export interface ChipModalFieldAria {
|
||||
|
||||
interface ChipModalCustomFieldProps extends ChipModalFieldBaseProps {
|
||||
type: 'custom'
|
||||
/**
|
||||
* Whether Enter in a nested plain single-line input should trigger the
|
||||
* footer's default primary action. Set `false` when the custom control owns
|
||||
* Enter, such as a search/filter or token editor.
|
||||
* @default true
|
||||
*/
|
||||
submitOnEnter?: boolean
|
||||
/**
|
||||
* Arbitrary JSX, or a function receiving the field's {@link ChipModalFieldAria}.
|
||||
*
|
||||
@@ -616,7 +729,6 @@ export type ChipModalFieldProps =
|
||||
*/
|
||||
function ChipModalField(props: ChipModalFieldProps) {
|
||||
const id = React.useId()
|
||||
const submitRef = React.useContext(ChipModalSubmitContext)
|
||||
const errorId = `${id}-error`
|
||||
const hintId = `${id}-hint`
|
||||
const { title, required, error, hint, flush = false, className } = props
|
||||
@@ -628,7 +740,12 @@ function ChipModalField(props: ChipModalFieldProps) {
|
||||
props.type === 'emails'
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-[9px]', flush ? 'px-0' : 'px-2', className)}>
|
||||
<div
|
||||
className={cn('flex flex-col gap-[9px]', flush ? 'px-0' : 'px-2', className)}
|
||||
data-chip-modal-enter-owner={
|
||||
props.type === 'custom' && props.submitOnEnter === false ? '' : undefined
|
||||
}
|
||||
>
|
||||
<Label htmlFor={associatesLabel ? id : undefined} className='pl-0.5 text-[var(--text-muted)]'>
|
||||
{title}
|
||||
{required && (
|
||||
@@ -637,7 +754,7 @@ function ChipModalField(props: ChipModalFieldProps) {
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{renderChipModalControl(props, id, errorId, hintId, submitRef)}
|
||||
{renderChipModalControl(props, id, errorId, hintId)}
|
||||
{error && props.type !== 'emails' ? (
|
||||
<p id={errorId} role='alert' className={CHIP_MODAL_FIELD_ERROR_CLASS}>
|
||||
{error}
|
||||
@@ -662,8 +779,7 @@ function renderChipModalControl(
|
||||
props: ChipModalFieldProps,
|
||||
id: string,
|
||||
errorId: string,
|
||||
hintId: string,
|
||||
submitRef: React.MutableRefObject<ChipModalSubmit | null> | null
|
||||
hintId: string
|
||||
): React.ReactNode {
|
||||
const aria = {
|
||||
'aria-required': props.required || undefined,
|
||||
@@ -675,7 +791,7 @@ function renderChipModalControl(
|
||||
case 'input':
|
||||
case 'email': {
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) =>
|
||||
handleSingleLineEnter(event, props, submitRef)
|
||||
handleSingleLineEnter(event, props)
|
||||
|
||||
if (props.type === 'input' && props.inputType === 'password') {
|
||||
return (
|
||||
@@ -766,24 +882,29 @@ function renderChipModalControl(
|
||||
*/
|
||||
function handleSingleLineEnter(
|
||||
event: React.KeyboardEvent<HTMLInputElement>,
|
||||
props: ChipModalSingleLineEnterProps,
|
||||
submitRef: React.MutableRefObject<ChipModalSubmit | null> | null
|
||||
props: ChipModalSingleLineEnterProps
|
||||
) {
|
||||
if (event.key !== 'Enter' || event.nativeEvent.isComposing) return
|
||||
if (!isPlainEnter(event)) return
|
||||
if (props.onSubmit) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onSubmit()
|
||||
return
|
||||
}
|
||||
if (props.submitOnEnter === false) return
|
||||
const submit = submitRef?.current
|
||||
if (submit && !submit.disabled) {
|
||||
if (props.submitOnEnter === false) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (event.currentTarget.form) return
|
||||
const content = event.currentTarget.closest<HTMLElement>('[role="dialog"]')
|
||||
const action = content ? findFocusableAction(content, CHIP_MODAL_SUBMIT_ACTION_SELECTOR) : null
|
||||
if (action) {
|
||||
event.preventDefault()
|
||||
// Stop bubbling so a parent Enter handler (e.g. a modal body that also
|
||||
// submits) can't fire the same primary action a second time.
|
||||
event.stopPropagation()
|
||||
submit.trigger()
|
||||
action.click()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1010,14 +1131,14 @@ function ChipModalFileControl({
|
||||
/**
|
||||
* A single footer action button. Rendered internally as a {@link Chip} so every
|
||||
* modal footer stays visually identical — callers describe intent (label,
|
||||
* handler, optional variant), never JSX or chrome. Encode pending state in the
|
||||
* `label` and `disabled` (e.g. `saving ? 'Saving...' : 'Save'`).
|
||||
* activation, optional variant), never JSX or chrome. Encode pending state in
|
||||
* the `label` and `disabled` (e.g. `saving ? 'Saving...' : 'Save'`).
|
||||
*/
|
||||
export interface ChipModalFooterAction {
|
||||
interface ChipModalFooterActionBase {
|
||||
/** Button label. */
|
||||
label: React.ReactNode
|
||||
/** Click handler. */
|
||||
onClick: () => void
|
||||
/** Optional content rendered before the label, such as a pending spinner. */
|
||||
leftAdornment?: React.ReactNode
|
||||
/** Disables the button. */
|
||||
disabled?: boolean
|
||||
/**
|
||||
@@ -1034,6 +1155,13 @@ export interface ChipModalFooterAction {
|
||||
variant?: Extract<ChipProps['variant'], 'primary' | 'destructive'>
|
||||
}
|
||||
|
||||
/** A footer action activated either directly or through an associated native form. */
|
||||
export type ChipModalFooterAction = ChipModalFooterActionBase &
|
||||
(
|
||||
| { type?: 'button'; onClick: () => void; form?: never }
|
||||
| { type: 'submit'; form: string; onClick?: never }
|
||||
)
|
||||
|
||||
/**
|
||||
* Escape hatch for the left-docked footer cluster: renders the given node in
|
||||
* place of a declarative action Chip. Reserve it for chip-chrome controls
|
||||
@@ -1051,16 +1179,9 @@ export interface ChipModalFooterCustomAction {
|
||||
/** One entry of the footer's left-docked `secondaryActions` cluster. */
|
||||
export type ChipModalFooterSlotAction = ChipModalFooterAction | ChipModalFooterCustomAction
|
||||
|
||||
export interface ChipModalFooterProps {
|
||||
/**
|
||||
* Dismiss handler for the Cancel button. For standard form footers Cancel is
|
||||
* structural — it always reads "Cancel" and cannot be relabeled. Its enabled
|
||||
* state is controlled via {@link ChipModalFooterProps.cancelDisabled}. A
|
||||
* multi-step/wizard footer whose own Back navigation plus the header close (X)
|
||||
* already cover dismissal may suppress it via
|
||||
* {@link ChipModalFooterProps.hideCancel}.
|
||||
*/
|
||||
onCancel: () => void
|
||||
export type ChipModalFooterDefaultAction = 'primary' | 'dismiss' | 'none'
|
||||
|
||||
interface ChipModalFooterCommonProps {
|
||||
/**
|
||||
* Disables the Cancel button. Set this while a primary/secondary action is
|
||||
* in flight (e.g. an async delete or save) so the user cannot dismiss the
|
||||
@@ -1073,15 +1194,14 @@ export interface ChipModalFooterProps {
|
||||
*/
|
||||
cancelDisabled?: boolean
|
||||
/**
|
||||
* Suppresses the Cancel button entirely. Reserve for multi-step/wizard footers
|
||||
* where the in-footer Back navigation plus the header close (X) already provide
|
||||
* dismissal, so a third dismiss affordance is redundant. Standard one-shot form
|
||||
* footers keep Cancel — do not hide it merely to declutter.
|
||||
* @default false
|
||||
* Declares the modal's keyboard default. A visible text field still receives
|
||||
* initial focus; pressing Enter there triggers `primary` only when this value
|
||||
* is `'primary'`. Without a text field, the corresponding real button gets
|
||||
* focus and therefore owns Enter natively. Use `'dismiss'` or `'none'` when
|
||||
* submission should require an explicit click.
|
||||
* @default 'primary'
|
||||
*/
|
||||
hideCancel?: boolean
|
||||
/** Primary action, anchored bottom-right (e.g. Save, Create, Delete). */
|
||||
primaryAction: ChipModalFooterAction
|
||||
defaultAction?: ChipModalFooterDefaultAction
|
||||
/**
|
||||
* An action rendered immediately to the LEFT of the {@link primaryAction},
|
||||
* inside the right-anchored cluster (after the structural Cancel). Use for the
|
||||
@@ -1104,8 +1224,37 @@ export interface ChipModalFooterProps {
|
||||
* describe intent, never chrome.
|
||||
*/
|
||||
secondaryActions?: ChipModalFooterSlotAction[]
|
||||
/** Non-interactive status or metadata docked to the far-left of the footer. */
|
||||
leadingContent?: React.ReactNode
|
||||
}
|
||||
|
||||
type ChipModalFooterCancelProps =
|
||||
| {
|
||||
/** Dismiss handler for the structural Cancel button. */
|
||||
onCancel: () => void
|
||||
/** Suppresses Cancel when another dismissal affordance already exists. */
|
||||
hideCancel?: boolean
|
||||
}
|
||||
| {
|
||||
/** A hidden Cancel button has no inert callback to configure. */
|
||||
onCancel?: never
|
||||
hideCancel: true
|
||||
}
|
||||
|
||||
export type ChipModalFooterProps = ChipModalFooterCommonProps &
|
||||
ChipModalFooterCancelProps &
|
||||
(
|
||||
| {
|
||||
/** Primary action, anchored bottom-right (e.g. Save, Create, Delete). */
|
||||
primaryAction: ChipModalFooterAction
|
||||
}
|
||||
| {
|
||||
/** A footer without a primary action must explicitly decline primary submission. */
|
||||
primaryAction?: undefined
|
||||
defaultAction: Exclude<ChipModalFooterDefaultAction, 'primary'>
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared footer chrome — the inset separator plus the tinted `--surface-3` bar
|
||||
* with the standard gutter. Single source of truth so {@link ChipModalFooter}
|
||||
@@ -1114,14 +1263,16 @@ export interface ChipModalFooterProps {
|
||||
* omitted the cluster is right-justified.
|
||||
*/
|
||||
function ChipModalFooterShell({
|
||||
defaultAction,
|
||||
leftSlot,
|
||||
children,
|
||||
}: {
|
||||
defaultAction: ChipModalFooterDefaultAction | ChipConfirmDefaultAction
|
||||
leftSlot?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex flex-col' data-chip-modal-default-policy={defaultAction}>
|
||||
<ChipModalSeparator />
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1144,7 +1295,14 @@ function ChipModalFooterShell({
|
||||
function renderFooterSlotAction(action: ChipModalFooterSlotAction): React.ReactNode {
|
||||
if ('custom' in action) return action.custom
|
||||
return (
|
||||
<Chip variant={action.variant} onClick={action.onClick} disabled={action.disabled}>
|
||||
<Chip
|
||||
type={action.type ?? 'button'}
|
||||
form={action.type === 'submit' ? action.form : undefined}
|
||||
variant={action.variant}
|
||||
leftAdornment={action.leftAdornment}
|
||||
onClick={action.type === 'submit' ? undefined : action.onClick}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.label}
|
||||
</Chip>
|
||||
)
|
||||
@@ -1152,8 +1310,8 @@ function renderFooterSlotAction(action: ChipModalFooterSlotAction): React.ReactN
|
||||
|
||||
/**
|
||||
* Footer row with a fixed, declarative shape: an optional far-left
|
||||
* `secondaryActions` cluster, then the always-present Cancel and the
|
||||
* right-anchored `primaryAction`. Buttons are described via
|
||||
* status/action cluster, then Cancel and the right-anchored `primaryAction`
|
||||
* when those decisions exist. Buttons are described via
|
||||
* {@link ChipModalFooterAction} and rendered as {@link Chip}s, so no footer
|
||||
* can drift from the canonical layout; the secondary entries additionally
|
||||
* accept a chip-chrome control via {@link ChipModalFooterCustomAction}.
|
||||
@@ -1166,58 +1324,39 @@ function ChipModalFooter({
|
||||
onCancel,
|
||||
cancelDisabled,
|
||||
hideCancel = false,
|
||||
defaultAction = 'primary',
|
||||
primaryAction,
|
||||
primaryAdjacentAction,
|
||||
secondaryActions,
|
||||
leadingContent,
|
||||
}: ChipModalFooterProps) {
|
||||
const dismissDisabled = useModalDismissDisabled()
|
||||
const showsDisabledTooltip = Boolean(primaryAction.disabled && primaryAction.disabledTooltip)
|
||||
const showsDisabledTooltip = Boolean(primaryAction?.disabled && primaryAction.disabledTooltip)
|
||||
|
||||
/**
|
||||
* Publish the primary action so single-line {@link ChipModalField}s can submit
|
||||
* the modal on Enter without per-field wiring. Kept in a ref (updated each
|
||||
* render) rather than state so fields read the latest handler at Enter-time.
|
||||
*
|
||||
* A layout effect (not a passive effect) so the ref is populated before the
|
||||
* browser paints the modal — otherwise there is a window between first paint
|
||||
* and effect commit where an enabled primary is visible but Enter does
|
||||
* nothing because `submitRef.current` is still `null`.
|
||||
*
|
||||
* A `destructive` primary is deliberately NOT published: Enter must never
|
||||
* trigger a destructive action from a text field. Destructive flows that DO
|
||||
* want Enter (e.g. a guarded "change address") wire an explicit field-level
|
||||
* `onSubmit`, which takes precedence over this fallback.
|
||||
*/
|
||||
const submitRef = React.useContext(ChipModalSubmitContext)
|
||||
React.useLayoutEffect(() => {
|
||||
if (!submitRef) return
|
||||
if (primaryAction.variant === 'destructive') {
|
||||
submitRef.current = null
|
||||
return
|
||||
}
|
||||
submitRef.current = { trigger: primaryAction.onClick, disabled: primaryAction.disabled }
|
||||
return () => {
|
||||
submitRef.current = null
|
||||
}
|
||||
}, [submitRef, primaryAction.onClick, primaryAction.disabled, primaryAction.variant])
|
||||
|
||||
const primaryChip = (
|
||||
const primaryChip = primaryAction ? (
|
||||
<Chip
|
||||
type={primaryAction.type ?? 'button'}
|
||||
form={primaryAction.type === 'submit' ? primaryAction.form : undefined}
|
||||
variant={primaryAction.variant ?? 'primary'}
|
||||
onClick={primaryAction.onClick}
|
||||
leftAdornment={primaryAction.leftAdornment}
|
||||
onClick={primaryAction.type === 'submit' ? undefined : primaryAction.onClick}
|
||||
disabled={primaryAction.disabled}
|
||||
className={cn(showsDisabledTooltip && 'pointer-events-none')}
|
||||
data-chip-modal-submit-action={defaultAction === 'primary' ? '' : undefined}
|
||||
data-chip-modal-default-action={defaultAction === 'primary' ? '' : undefined}
|
||||
>
|
||||
{primaryAction.label}
|
||||
</Chip>
|
||||
)
|
||||
) : null
|
||||
|
||||
return (
|
||||
<ChipModalFooterShell
|
||||
defaultAction={defaultAction}
|
||||
leftSlot={
|
||||
secondaryActions && secondaryActions.length > 0 ? (
|
||||
leadingContent != null || (secondaryActions && secondaryActions.length > 0) ? (
|
||||
<div className='flex min-w-0 flex-wrap items-center gap-2'>
|
||||
{secondaryActions.map((action, index) => (
|
||||
{leadingContent}
|
||||
{secondaryActions?.map((action, index) => (
|
||||
<React.Fragment key={index}>{renderFooterSlotAction(action)}</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
@@ -1225,12 +1364,17 @@ function ChipModalFooter({
|
||||
}
|
||||
>
|
||||
{hideCancel ? null : (
|
||||
<Chip onClick={onCancel} disabled={cancelDisabled || dismissDisabled}>
|
||||
<Chip
|
||||
onClick={onCancel}
|
||||
disabled={cancelDisabled || dismissDisabled}
|
||||
data-chip-modal-dismiss-action=''
|
||||
data-chip-modal-default-action={defaultAction === 'dismiss' ? '' : undefined}
|
||||
>
|
||||
Cancel
|
||||
</Chip>
|
||||
)}
|
||||
{primaryAdjacentAction ? renderFooterSlotAction(primaryAdjacentAction) : null}
|
||||
{showsDisabledTooltip ? (
|
||||
{showsDisabledTooltip && primaryAction ? (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span className='inline-flex cursor-not-allowed'>{primaryChip}</span>
|
||||
@@ -1320,6 +1464,8 @@ export type ChipConfirmTextSegment =
|
||||
*/
|
||||
export type ChipConfirmText = string | readonly ChipConfirmTextSegment[]
|
||||
|
||||
export type ChipConfirmDefaultAction = 'confirm' | 'dismiss' | 'none'
|
||||
|
||||
/** True when `text` resolves to at least one non-empty run. */
|
||||
function hasChipConfirmText(text: ChipConfirmText | undefined): text is ChipConfirmText {
|
||||
if (text === undefined) return false
|
||||
@@ -1391,6 +1537,14 @@ export interface ChipConfirmModalProps {
|
||||
children?: React.ReactNode
|
||||
/** The confirming action (Delete / Discard / Remove …). */
|
||||
confirm: ChipConfirmAction
|
||||
/**
|
||||
* Declares the confirmation's keyboard default independently from button
|
||||
* color. Confirmations fail safe to `'dismiss'`; audited reversible or
|
||||
* non-destructive flows may opt into `'confirm'`, while severe guarded flows
|
||||
* can use `'none'` to require an explicit final click.
|
||||
* @default 'dismiss'
|
||||
*/
|
||||
defaultAction?: ChipConfirmDefaultAction
|
||||
/**
|
||||
* Label for the dismiss button. In a confirmation the dismiss button is a
|
||||
* named decision, so this is honest API (unlike a form footer's structural
|
||||
@@ -1412,7 +1566,11 @@ export interface ChipConfirmModalProps {
|
||||
* explained why. A disabled `<button>` is not a hit-test target, so the wrapping
|
||||
* span carries `pointer-events-none` off the chip for the tooltip to fire.
|
||||
*/
|
||||
function renderChipConfirmButton(confirm: ChipConfirmAction, confirmLabel: string) {
|
||||
function renderChipConfirmButton(
|
||||
confirm: ChipConfirmAction,
|
||||
confirmLabel: string,
|
||||
defaultAction: ChipConfirmDefaultAction
|
||||
) {
|
||||
const disabled = Boolean(confirm.disabled || confirm.pending)
|
||||
const chip = (
|
||||
<Chip
|
||||
@@ -1420,6 +1578,8 @@ function renderChipConfirmButton(confirm: ChipConfirmAction, confirmLabel: strin
|
||||
onClick={confirm.onClick}
|
||||
disabled={disabled}
|
||||
className={cn(confirm.disabledTooltip && disabled && 'pointer-events-none')}
|
||||
data-chip-modal-submit-action={defaultAction === 'confirm' ? '' : undefined}
|
||||
data-chip-modal-default-action={defaultAction === 'confirm' ? '' : undefined}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Chip>
|
||||
@@ -1435,6 +1595,36 @@ function renderChipConfirmButton(confirm: ChipConfirmAction, confirmLabel: strin
|
||||
)
|
||||
}
|
||||
|
||||
interface ChipConfirmModalFooterProps {
|
||||
confirm: ChipConfirmAction
|
||||
confirmLabel: string
|
||||
defaultAction: ChipConfirmDefaultAction
|
||||
dismiss: () => void
|
||||
dismissLabel: string
|
||||
}
|
||||
|
||||
function ChipConfirmModalFooter({
|
||||
confirm,
|
||||
confirmLabel,
|
||||
defaultAction,
|
||||
dismiss,
|
||||
dismissLabel,
|
||||
}: ChipConfirmModalFooterProps) {
|
||||
return (
|
||||
<ChipModalFooterShell defaultAction={defaultAction}>
|
||||
<Chip
|
||||
onClick={dismiss}
|
||||
disabled={confirm.pending}
|
||||
data-chip-modal-dismiss-action=''
|
||||
data-chip-modal-default-action={defaultAction === 'dismiss' ? '' : undefined}
|
||||
>
|
||||
{dismissLabel}
|
||||
</Chip>
|
||||
{renderChipConfirmButton(confirm, confirmLabel, defaultAction)}
|
||||
</ChipModalFooterShell>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact "are you sure?" confirmation dialog. Models the confirmation button
|
||||
* grammar directly — a named dismiss decision plus a (usually destructive)
|
||||
@@ -1470,12 +1660,15 @@ function ChipConfirmModal({
|
||||
text,
|
||||
children,
|
||||
confirm,
|
||||
defaultAction = 'dismiss',
|
||||
dismissLabel = 'Cancel',
|
||||
size = 'sm',
|
||||
srTitle,
|
||||
}: ChipConfirmModalProps) {
|
||||
const dismiss = React.useCallback(() => onOpenChange(false), [onOpenChange])
|
||||
const dismiss = () => onOpenChange(false)
|
||||
const confirmLabel = confirm.pending ? (confirm.pendingLabel ?? confirm.label) : confirm.label
|
||||
const descriptionId = React.useId()
|
||||
const hasText = hasChipConfirmText(text)
|
||||
|
||||
return (
|
||||
<ChipModal
|
||||
@@ -1484,24 +1677,26 @@ function ChipConfirmModal({
|
||||
size={size}
|
||||
srTitle={srTitle ?? (typeof title === 'string' ? title : 'Confirm')}
|
||||
dismissDisabled={confirm.pending}
|
||||
aria-describedby={hasText ? descriptionId : undefined}
|
||||
>
|
||||
<ChipModalHeader icon={icon} onClose={dismiss}>
|
||||
{title}
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
{hasChipConfirmText(text) ? (
|
||||
<p className='break-words px-2 text-[var(--text-primary)] text-sm'>
|
||||
{hasText ? (
|
||||
<p id={descriptionId} className='break-words px-2 text-[var(--text-primary)] text-sm'>
|
||||
{renderChipConfirmText(text)}
|
||||
</p>
|
||||
) : null}
|
||||
{children}
|
||||
</ChipModalBody>
|
||||
<ChipModalFooterShell>
|
||||
<Chip onClick={dismiss} disabled={confirm.pending}>
|
||||
{dismissLabel}
|
||||
</Chip>
|
||||
{renderChipConfirmButton(confirm, confirmLabel)}
|
||||
</ChipModalFooterShell>
|
||||
<ChipConfirmModalFooter
|
||||
confirm={confirm}
|
||||
confirmLabel={confirmLabel}
|
||||
defaultAction={defaultAction}
|
||||
dismiss={dismiss}
|
||||
dismissLabel={dismissLabel}
|
||||
/>
|
||||
</ChipModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -659,6 +659,11 @@ const Combobox = memo(
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
{...inputProps}
|
||||
role='combobox'
|
||||
aria-expanded={open}
|
||||
aria-haspopup='listbox'
|
||||
aria-controls={listboxId}
|
||||
aria-autocomplete='list'
|
||||
/>
|
||||
{(overlayContent || SelectedIcon) && (
|
||||
<div
|
||||
|
||||
@@ -53,12 +53,14 @@ export {
|
||||
export { ChipInput, type ChipInputProps } from './chip-input/chip-input'
|
||||
export {
|
||||
type ChipConfirmAction,
|
||||
type ChipConfirmDefaultAction,
|
||||
ChipConfirmModal,
|
||||
type ChipConfirmModalProps,
|
||||
type ChipConfirmText,
|
||||
type ChipConfirmTextSegment,
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
type ChipModalBodyProps,
|
||||
type ChipModalDropdownOption,
|
||||
type ChipModalEmailsFieldProps,
|
||||
ChipModalError,
|
||||
@@ -68,6 +70,7 @@ export {
|
||||
ChipModalFooter,
|
||||
type ChipModalFooterAction,
|
||||
type ChipModalFooterCustomAction,
|
||||
type ChipModalFooterDefaultAction,
|
||||
type ChipModalFooterProps,
|
||||
type ChipModalFooterSlotAction,
|
||||
ChipModalHeader,
|
||||
|
||||
@@ -18,7 +18,7 @@ const TEXT_INPUT_SELECTOR = [
|
||||
'[contenteditable=""]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
function isVisible(el: HTMLElement): boolean {
|
||||
export function isElementVisible(el: HTMLElement): boolean {
|
||||
return el.offsetParent !== null || el.getClientRects().length > 0
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ function isVisible(el: HTMLElement): boolean {
|
||||
*/
|
||||
export function focusFirstTextInputIn(root: HTMLElement | null): boolean {
|
||||
if (!root) return false
|
||||
const target = Array.from(root.querySelectorAll<HTMLElement>(TEXT_INPUT_SELECTOR)).find(isVisible)
|
||||
const target = Array.from(root.querySelectorAll<HTMLElement>(TEXT_INPUT_SELECTOR)).find(
|
||||
isElementVisible
|
||||
)
|
||||
if (!target) return false
|
||||
|
||||
target.focus({ preventScroll: false })
|
||||
|
||||
Reference in New Issue
Block a user