diff --git a/components/layout/__tests__/sidebar-apps-modal.test.tsx b/components/layout/__tests__/sidebar-apps-modal.test.tsx new file mode 100644 index 000000000..b39e09c6d --- /dev/null +++ b/components/layout/__tests__/sidebar-apps-modal.test.tsx @@ -0,0 +1,40 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// The "Apps" modal on the shared dialog shell: a named close button, a +// backdrop that closes it, Escape from anywhere inside, named row buttons. + +vi.mock('@/hooks/use-resolved-sidebar-apps', () => ({ useManagedSidebarApps: () => [] })); + +const { SidebarAppsModal } = await import('../sidebar-apps-modal'); +const { useSettingsStore } = await import('@/stores/settings-store'); + +describe('apps modal', () => { + it('closes from its named X and from the backdrop', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'close' })); + expect(onClose).toHaveBeenCalledTimes(1); + + const backdrop = screen.getByRole('dialog').parentElement!; + fireEvent.mouseDown(backdrop); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('closes on Escape after a click on its heading', () => { + const onClose = vi.fn(); + render(); + const heading = screen.getByRole('heading', { name: 'modal_title' }); + fireEvent.mouseDown(heading); + fireEvent.keyDown(heading, { key: 'Escape' }); + expect(onClose).toHaveBeenCalled(); + }); + + it('names the edit and delete buttons of each app', () => { + useSettingsStore.setState({ sidebarApps: [{ id: 'a', name: 'Wiki', url: 'https://wiki.example', icon: 'Globe', openMode: 'tab' }] } as never); + render(); + expect(screen.getByRole('button', { name: 'edit_app' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'delete' })).toBeInTheDocument(); + }); +}); diff --git a/components/layout/sidebar-apps-modal.tsx b/components/layout/sidebar-apps-modal.tsx index cd1c0b0fa..a9d8e7043 100644 --- a/components/layout/sidebar-apps-modal.tsx +++ b/components/layout/sidebar-apps-modal.tsx @@ -2,15 +2,16 @@ import { useState, useCallback } from 'react'; import { useTranslations } from 'next-intl'; -import { X, Plus, Pencil, Trash2, ExternalLink, PanelRight, Lock } from '@/components/icons'; +import { Plus, Pencil, Trash2, ExternalLink, PanelRight, Lock } from '@/components/icons'; import { iconForName } from '@/components/icons'; import { cn, generateUUID } from '@/lib/utils'; import { Button } from '@/components/ui/button'; +import { IconButton } from '@/components/ui/icon-button'; import { Input } from '@/components/ui/input'; import { IconPicker } from './icon-picker'; import { useSettingsStore, type SidebarApp } from '@/stores/settings-store'; import { useManagedSidebarApps } from '@/hooks/use-resolved-sidebar-apps'; -import { useFocusTrap } from '@/hooks/use-focus-trap'; +import { Dialog } from '@/components/ui/dialog'; import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -240,18 +241,17 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) { const [isCreating, setIsCreating] = useState(false); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); - const modalRef = useFocusTrap({ - isActive: isOpen, - onEscape: () => { - if (isCreating || editingId) { - setIsCreating(false); - setEditingId(null); - } else { - onClose(); - } - }, - restoreFocus: true, - }); + // Escape, the X and an outside click leave an open app form first, then + // the dialog; a click outside is ignored while a form is open. + const editing = isCreating || !!editingId; + const handleClose = useCallback(() => { + if (editing) { + setIsCreating(false); + setEditingId(null); + } else { + onClose(); + } + }, [editing, onClose]); const handleCreate = useCallback((data: SidebarAppFormData) => { const id = `app-${generateUUID()}`; @@ -275,36 +275,17 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) { removeSidebarApp(app.id); }, [removeSidebarApp, confirmDialog, t]); - if (!isOpen) return null; - return ( -
-
+ - {/* Header */} -
- - -
- - {/* Content */} -
{/* Create form */} @@ -368,22 +349,21 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) { {app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
- - + +
); @@ -397,10 +377,9 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
)}
- - + - + ); }