Converge modal and drawer frames

This commit is contained in:
Richard R 2026-06-01 08:27:27 -06:00
parent 9b424f0c9f
commit bd3b99b3ee
7 changed files with 215 additions and 172 deletions

View file

@ -1,6 +1,5 @@
import { Fragment, KeyboardEvent } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { Button, dialogPanelStyles } from '@/components/ui';
import { KeyboardEvent } from 'react';
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
interface ConfirmDialogProps {
isOpen: boolean;
@ -31,66 +30,25 @@ export function ConfirmDialog({
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog
as="div"
role={undefined}
className="relative z-50"
onClose={onClose}
onKeyDown={handleKeyDown}
>
<TransitionChild
as={Fragment}
enter="ease-standard duration-slow"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-standard duration-base"
leaveFrom="opacity-100"
leaveTo="opacity-0"
<ModalFrame open={isOpen} onClose={onClose} onKeyDown={handleKeyDown}>
<ModalTitle>{title}</ModalTitle>
<div className="mt-2">
<p className="text-sm text-soft break-words">{message}</p>
</div>
<div className="mt-6 flex justify-end space-x-3">
<Button variant="outline" size="sm" onClick={onClose}>
{cancelText}
</Button>
<Button
variant={isDangerous ? 'danger' : 'primary'}
size="sm"
className="text-wrap"
onClick={onConfirm}
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-standard duration-slow"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-standard duration-base"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel role='dialog' className={dialogPanelStyles({ size: 'md' })}>
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground"
>
{title}
</DialogTitle>
<div className="mt-2">
<p className="text-sm text-soft break-words">{message}</p>
</div>
<div className="mt-6 flex justify-end space-x-3">
<Button variant="outline" size="sm" onClick={onClose}>
{cancelText}
</Button>
<Button
variant={isDangerous ? 'danger' : 'primary'}
size="sm"
className="text-wrap"
onClick={onConfirm}
>
{confirmText}
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
{confirmText}
</Button>
</div>
</ModalFrame>
);
}

View file

@ -11,8 +11,6 @@ import {
ListboxButton,
ListboxOptions,
ListboxOption,
Button,
Input,
} from '@headlessui/react';
import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext';
@ -55,8 +53,10 @@ import {
SidebarNav,
SidebarNavItem,
SegmentedControl,
Button,
IconButton,
Input,
inputClass,
buttonClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
@ -142,8 +142,10 @@ export function SettingsTrigger({
}) {
return (
<Button
variant="secondary"
size="sm"
onClick={onOpen}
className={`inline-flex items-center py-1 px-2 rounded-md border border-line bg-surface text-foreground text-xs hover:bg-accent-wash hover:text-accent transition-transform transition-colors duration-base ease-standard ${className}`}
className={className}
aria-label="Settings"
tabIndex={0}
>
@ -549,6 +551,8 @@ export function SettingsModal({
Settings
</DialogTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setIsChangelogOpen(true)}
className="text-sm font-medium leading-6 text-soft hover:text-accent transition-colors"
>
@ -557,6 +561,8 @@ export function SettingsModal({
</div>
<div className="flex items-center">
<Button
variant="ghost"
size="sm"
onClick={() => showPrivacyModal()}
className="text-sm font-medium text-soft hover:text-accent transition-colors"
>
@ -842,7 +848,8 @@ export function SettingsModal({
<div className="pt-4 flex justify-end gap-2">
<Button
type="button"
className={buttonClass({ variant: 'secondary', size: 'md' })}
variant="secondary"
size="md"
onClick={async () => {
const defaults = resolveProviderDefaults({
providerRef: runtimeConfig.defaultTtsProvider,
@ -862,7 +869,8 @@ export function SettingsModal({
<Button
data-testid="settings-save-button"
type="button"
className={buttonClass({ variant: 'primary', size: 'md' })}
variant="primary"
size="md"
disabled={!canSubmit}
onClick={async () => {
const defaults = resolveProviderDefaults({
@ -1128,7 +1136,8 @@ export function SettingsModal({
<Button
onClick={handleImportLibrary}
disabled={isBusy}
className={buttonClass({ variant: 'outline', size: 'md' })}
variant="outline"
size="md"
>
{isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'}
</Button>
@ -1140,14 +1149,16 @@ export function SettingsModal({
<Button
onClick={handleRefresh}
disabled={isBusy}
className={buttonClass({ variant: 'outline', size: 'md' })}
variant="outline"
size="md"
>
Refresh
</Button>
<Button
onClick={handleClearCache}
disabled={isBusy}
className={buttonClass({ variant: 'outline', size: 'md' })}
variant="outline"
size="md"
>
Clear cache
</Button>
@ -1226,7 +1237,8 @@ export function SettingsModal({
<>
<Button
onClick={handleSignOut}
className={buttonClass({ variant: 'outline', size: 'md' })}
variant="outline"
size="md"
>
Disconnect account
</Button>
@ -1236,7 +1248,8 @@ export function SettingsModal({
<label className="block text-sm font-medium text-danger mb-2">Danger Zone</label>
<Button
onClick={() => setShowDeleteAccountConfirm(true)}
className={buttonClass({ variant: 'danger', size: 'md' })}
variant="danger"
size="md"
>
Delete Account
</Button>
@ -1259,19 +1272,19 @@ export function SettingsModal({
</p>
<div className="flex flex-wrap gap-2">
<Link href="/signin">
<Button className={buttonClass({ variant: 'outline', size: 'md' })}>
<Button variant="outline" size="md">
Connect
</Button>
</Link>
{runtimeConfig.enableUserSignups && (
<Link href="/signup">
<Button className={buttonClass({ variant: 'primary', size: 'md' })}>
<Button variant="primary" size="md">
Create account
</Button>
</Link>
)}
<Link href="/?redirect=false">
<Button className={buttonClass({ variant: 'outline', size: 'md' })}>
<Button variant="outline" size="md">
Back to landing page
</Button>
</Link>
@ -1418,16 +1431,15 @@ function SettingsChangelogPanel({
return (
<div className="h-[490px] flex flex-col bg-surface">
<div className="flex items-center gap-3 px-4 py-3 border-b border-line-soft bg-background">
<Button
<IconButton
onClick={onClose}
className="inline-flex items-center justify-center rounded-md text-soft hover:text-accent hover:bg-surface transition duration-base ease-standard transform"
aria-label="Back to settings"
title="Back"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</Button>
</IconButton>
<div className="min-w-0">
<h4 className="text-sm font-semibold text-foreground">Changelog</h4>
<p className="text-xs text-soft truncate">

View file

@ -1,7 +1,7 @@
'use client';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Button, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
@ -13,7 +13,7 @@ import {
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
buttonClass,
Button,
} from '@/components/ui';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
@ -581,14 +581,16 @@ export function AdminFeaturesPanel() {
<Button
onClick={discardAll}
disabled={dirty.size === 0 || saving}
className={buttonClass({ variant: 'secondary', size: 'sm' })}
variant="secondary"
size="sm"
>
Discard
</Button>
<Button
onClick={saveAll}
disabled={dirty.size === 0 || saving}
className={buttonClass({ variant: 'primary', size: 'sm' })}
variant="primary"
size="sm"
>
{saving ? 'Saving…' : dirty.size > 0 ? `Save (${dirty.size})` : 'Save'}
</Button>

View file

@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
@ -17,7 +17,8 @@ import {
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
buttonClass,
Button,
Input,
} from '@/components/ui';
type ProviderType = TtsProviderId;
@ -364,7 +365,9 @@ export function AdminProvidersPanel() {
{!editingId ? (
<Button
onClick={startCreate}
className={buttonClass({ variant: 'primary', size: 'icon', className: 'h-7 w-7' })}
variant="primary"
size="icon"
className="h-7 w-7"
aria-label="Add provider"
title="Add provider"
>
@ -589,13 +592,14 @@ export function AdminProvidersPanel() {
/>
<div className="pt-1 flex justify-end gap-2">
<Button onClick={cancelEdit} className={buttonClass({ variant: 'secondary', size: 'sm' })}>
<Button onClick={cancelEdit} variant="secondary" size="sm">
Cancel
</Button>
<Button
onClick={submit}
disabled={submitDisabled}
className={buttonClass({ variant: 'primary', size: 'sm' })}
variant="primary"
size="sm"
>
{saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
</Button>

View file

@ -1,6 +1,5 @@
import { Fragment, KeyboardEvent } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { dialogPanelStyles, Input } from '@/components/ui';
import { KeyboardEvent } from 'react';
import { Input, ModalFrame, ModalTitle } from '@/components/ui';
interface CreateFolderDialogProps {
isOpen: boolean;
@ -18,52 +17,20 @@ export function CreateFolderDialog({
onClose,
}: CreateFolderDialogProps) {
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<TransitionChild
as={Fragment}
enter="ease-standard duration-slow"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-standard duration-base"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-standard duration-slow"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-standard duration-base"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className={dialogPanelStyles({ size: 'md' })}>
<DialogTitle as="h3" className="text-lg font-semibold text-foreground">
Create New Folder
</DialogTitle>
<div className="mt-4">
<Input
type="text"
value={folderName}
onChange={(e) => onFolderNameChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Enter folder name"
controlSize="lg"
autoFocus
/>
<p className="mt-2 text-xs text-soft">Press Enter to create or Escape to cancel</p>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
<ModalFrame open={isOpen} onClose={onClose}>
<ModalTitle>Create New Folder</ModalTitle>
<div className="mt-4">
<Input
type="text"
value={folderName}
onChange={(e) => onFolderNameChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Enter folder name"
controlSize="lg"
autoFocus
/>
<p className="mt-2 text-xs text-soft">Press Enter to create or Escape to cancel</p>
</div>
</ModalFrame>
);
}

View file

@ -1,8 +1,8 @@
'use client';
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
import { Fragment, useEffect, useState, type ReactNode } from 'react';
import { useEffect, useState, type ReactNode } from 'react';
import { cn } from '@/components/ui/cn';
import { DrawerFrame } from '@/components/ui';
const NARROW_QUERY = '(max-width: 767px)';
@ -65,35 +65,10 @@ function LibrarySidebarDrawer({
children: ReactNode;
}) {
return (
<Transition show={open} as={Fragment}>
<Dialog onClose={onClose ?? (() => undefined)} className="relative z-40 md:hidden">
<TransitionChild
as={Fragment}
enter="transition-opacity duration-fast"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-y-0 left-0 flex max-w-full">
<TransitionChild
as={Fragment}
enter="transition-transform duration-base ease-standard"
enterFrom="-translate-x-full"
enterTo="translate-x-0"
leave="transition-transform duration-fast ease-standard"
leaveFrom="translate-x-0"
leaveTo="-translate-x-full"
>
<DialogPanel className="h-full w-[80vw] max-w-[280px] bg-surface shadow-elev-3">
{children}
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
<div className="md:hidden">
<DrawerFrame open={open} onClose={onClose ?? (() => undefined)} side="left" className="w-[80vw] max-w-[280px]">
{children}
</DrawerFrame>
</div>
);
}

View file

@ -1,5 +1,9 @@
import type { ReactNode } from 'react';
'use client';
import { Fragment, type KeyboardEventHandler, type ReactNode } from 'react';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { variants } from './variants';
import { cn } from './cn';
export type DialogSize = 'sm' | 'md' | 'lg' | 'xl';
@ -29,3 +33,124 @@ export function DialogShell({
}) {
return <div className={dialogPanelStyles({ size, className })}>{children}</div>;
}
export function ModalFrame({
open,
onClose,
children,
size = 'md',
className,
panelClassName,
panelTestId,
onKeyDown,
}: {
open: boolean;
onClose: () => void;
children: ReactNode;
size?: DialogSize;
className?: string;
panelClassName?: string;
panelTestId?: string;
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
}) {
return (
<Transition appear show={open} as={Fragment}>
<Dialog as="div" role={undefined} className={cn('relative z-50', className)} onClose={onClose} onKeyDown={onKeyDown}>
<TransitionChild
as={Fragment}
enter="ease-standard duration-slow"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-standard duration-base"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-standard duration-slow"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-standard duration-base"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel data-testid={panelTestId} className={dialogPanelStyles({ size, className: panelClassName })}>
{children}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
export function ModalTitle({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<DialogTitle as="h3" className={cn('text-lg font-semibold leading-6 text-foreground', className)}>
{children}
</DialogTitle>
);
}
export function DrawerFrame({
open,
onClose,
children,
side = 'left',
className,
}: {
open: boolean;
onClose: () => void;
children: ReactNode;
side?: 'left' | 'right';
className?: string;
}) {
const sideClass = side === 'left' ? 'left-0' : 'right-0';
const enterFrom = side === 'left' ? '-translate-x-full' : 'translate-x-full';
const leaveTo = enterFrom;
return (
<Transition show={open} as={Fragment}>
<Dialog onClose={onClose} className="relative z-40">
<TransitionChild
as={Fragment}
enter="transition-opacity duration-fast"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
</TransitionChild>
<div className={cn('fixed inset-y-0 flex max-w-full', sideClass)}>
<TransitionChild
as={Fragment}
enter="transition-transform duration-base ease-standard"
enterFrom={enterFrom}
enterTo="translate-x-0"
leave="transition-transform duration-fast ease-standard"
leaveFrom="translate-x-0"
leaveTo={leaveTo}
>
<DialogPanel className={cn('h-full bg-surface shadow-elev-3', className)}>
{children}
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
);
}