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:
Waleed
2026-08-24 00:43:08 -07:00
committed by GitHub
parent 1aa714cf91
commit 82b02fb4d9
37 changed files with 1173 additions and 501 deletions
+10
View File
@@ -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.
-3
View File
@@ -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>
@@ -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 },
@@ -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 },
@@ -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>
@@ -43,6 +43,7 @@ export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
onOpenChange={onClose}
srTitle='Delete Knowledge Base'
title='Delete Knowledge Base'
defaultAction='dismiss'
text={
knowledgeBaseName
? [
@@ -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,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 },
}))
@@ -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>
@@ -96,6 +96,7 @@ export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountM
}}
size='md'
title='Delete account'
defaultAction='none'
confirm={{
label: 'Delete account',
pendingLabel: 'Deleting...',
@@ -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,
@@ -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}
@@ -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 },
@@ -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 },
@@ -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>
)}
</>
)
@@ -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}
/>
)
}
@@ -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 },
@@ -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
+3
View File
@@ -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 })