Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .claude/rules/emcn-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 0 additions & 3 deletions apps/sim/app/(auth)/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
/>
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, {})
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ export function ShareModal({
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
defaultAction={isUnshareAction ? 'none' : 'primary'}
secondaryActions={
saved?.isActive && saved.url
? [
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'>
Expand Down Expand Up @@ -737,6 +737,7 @@ export function DocumentTagsModal({

<ChipModalFooter
onCancel={() => handleClose(false)}
defaultAction='none'
primaryAction={{ label: 'Close', onClick: () => handleClose(false) }}
/>
</ChipModal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<ChipModalBody>
<ChipModalField
type='custom'
submitOnEnter={false}
title={
<>
Tags:{' '}
Expand Down Expand Up @@ -389,6 +390,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM

<ChipModalFooter
onCancel={() => handleClose(false)}
defaultAction='none'
primaryAction={{ label: 'Close', onClick: () => handleClose(false) }}
/>
</ChipModal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const DeleteKnowledgeBaseModal = memo(function DeleteKnowledgeBaseModal({
onOpenChange={onClose}
srTitle='Delete Knowledge Base'
title='Delete Knowledge Base'
defaultAction='dismiss'
text={
knowledgeBaseName
? [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
'use client'

import type React from 'react'
import { useState } from 'react'
import { useId, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalHeader,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Duplicate,
Loader,
Modal,
ModalBody,
ModalContent,
ModalDescription,
ModalHeader,
} from '@sim/emcn'
import { CircleAlert } from '@sim/emcn/icons'
import { createPortal } from 'react-dom'
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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}
</>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}))

Expand Down
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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>
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ export function Admin() {
}}
srTitle='Ban user'
title='Ban user'
defaultAction='none'
text={[
'Banning ',
{ text: pendingUser?.email ?? 'this user', bold: true },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ',
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -472,7 +469,6 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
onChange={setNameInput}
placeholder='e.g. Production key'
maxLength={120}
onSubmit={handleSave}
/>
)}
<ChipModalError>{error}</ChipModalError>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountM
}}
size='md'
title='Delete account'
defaultAction='none'
confirm={{
label: 'Delete account',
pendingLabel: 'Deleting...',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,14 +362,14 @@ export function InboxSettingsTab() {
setNewUsername(value)
if (editAddressError) setEditAddressError(null)
}}
onSubmit={handleEditAddress}
placeholder='e.g., new-acme'
error={editAddressError}
/>
</ChipModalBody>
<ChipModalFooter
onCancel={() => setIsEditAddressOpen(false)}
cancelDisabled={updateAddress.isPending}
defaultAction='none'
primaryAction={{
label: updateAddress.isPending ? 'Updating...' : 'Change address',
onClick: handleEditAddress,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function TransferOwnershipDialog({
onOpenChange={handleClose}
srTitle='Leave organization'
title='Leave organization'
defaultAction='none'
confirm={{
label: 'Transfer & leave',
onClick: handleConfirm,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand Down
Loading
Loading