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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
'use client'; 'use client';
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'; import { useEffect, useState, type ReactNode } from 'react';
import { Fragment, useEffect, useState, type ReactNode } from 'react';
import { cn } from '@/components/ui/cn'; import { cn } from '@/components/ui/cn';
import { DrawerFrame } from '@/components/ui';
const NARROW_QUERY = '(max-width: 767px)'; const NARROW_QUERY = '(max-width: 767px)';
@ -65,35 +65,10 @@ function LibrarySidebarDrawer({
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<Transition show={open} as={Fragment}> <div className="md:hidden">
<Dialog onClose={onClose ?? (() => undefined)} className="relative z-40 md:hidden"> <DrawerFrame open={open} onClose={onClose ?? (() => undefined)} side="left" className="w-[80vw] max-w-[280px]">
<TransitionChild {children}
as={Fragment} </DrawerFrame>
enter="transition-opacity duration-fast" </div>
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>
); );
} }

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 { variants } from './variants';
import { cn } from './cn';
export type DialogSize = 'sm' | 'md' | 'lg' | 'xl'; export type DialogSize = 'sm' | 'md' | 'lg' | 'xl';
@ -29,3 +33,124 @@ export function DialogShell({
}) { }) {
return <div className={dialogPanelStyles({ size, className })}>{children}</div>; 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>
);
}