Converge choice and popover surfaces

This commit is contained in:
Richard R 2026-06-01 11:02:26 -06:00
parent 44989700a6
commit d2d06e15b6
6 changed files with 130 additions and 124 deletions

View file

@ -4,6 +4,7 @@ import { useRef, useState, useEffect, useCallback } from 'react';
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'; import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext'; import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
import { PaletteIcon } from '@/components/icons/Icons'; import { PaletteIcon } from '@/components/icons/Icons';
import { IconButton, Input, cn, popoverPanelClass } from '@/components/ui';
/** /**
* Curated swatch palettes per color role, sourced from existing themes * Curated swatch palettes per color role, sourced from existing themes
@ -78,7 +79,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
return ( return (
<Popover className="relative flex items-center"> <Popover className="relative flex items-center">
<PopoverButton className="cursor-pointer group focus:outline-none"> <PopoverButton as={IconButton} size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
<div <div
className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1" className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
style={{ style={{
@ -91,8 +92,10 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
<PopoverPanel <PopoverPanel
anchor="bottom start" anchor="bottom start"
transition transition
className="z-[60] mt-2 w-56 rounded-lg shadow-elev-3 border border-line bg-background p-3 space-y-3 className={cn(
transition duration-fast ease-standard data-[closed]:opacity-0 data-[closed]:scale-95" popoverPanelClass,
'z-[60] mt-2 w-56 bg-background space-y-3 transition duration-fast ease-standard data-[closed]:opacity-0 data-[closed]:scale-95',
)}
> >
{/* Label */} {/* Label */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -101,14 +104,14 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
</span> </span>
{/* Eyedropper / native picker */} {/* Eyedropper / native picker */}
<div className="relative"> <div className="relative">
<button <IconButton
type="button" type="button"
onClick={() => nativeRef.current?.click()} onClick={() => nativeRef.current?.click()}
className="p-1" size="xs"
aria-label="Open system color picker" aria-label="Open system color picker"
> >
<PaletteIcon className="w-4 h-4 text-soft transform transition-transform duration-base ease-standard hover:text-accent" /> <PaletteIcon className="w-4 h-4 text-soft transform transition-transform duration-base ease-standard hover:text-accent" />
</button> </IconButton>
<input <input
ref={nativeRef} ref={nativeRef}
type="color" type="color"
@ -156,7 +159,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
borderColor: isLightColor(value) ? '#00000018' : '#ffffff18', borderColor: isLightColor(value) ? '#00000018' : '#ffffff18',
}} }}
/> />
<input <Input
type="text" type="text"
value={hexInput} value={hexInput}
onChange={(e) => setHexInput(e.target.value)} onChange={(e) => setHexInput(e.target.value)}
@ -166,7 +169,8 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
}} }}
spellCheck={false} spellCheck={false}
maxLength={7} maxLength={7}
className="flex-1 rounded-lg px-2 py-1 text-xs font-mono border border-line bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-accent-line" controlSize="sm"
className="flex-1 font-mono"
/> />
</div> </div>
</PopoverPanel> </PopoverPanel>

View file

@ -50,6 +50,7 @@ import {
SidebarNavItem, SidebarNavItem,
SegmentedControl, SegmentedControl,
Button, Button,
ChoiceTile,
IconButton, IconButton,
Input, Input,
ModalFrame, ModalFrame,
@ -109,6 +110,54 @@ const CUSTOM_COLOR_FIELDS: { key: keyof CustomThemeColors; label: string }[] = [
{ key: 'muted', label: 'Muted' }, { key: 'muted', label: 'Muted' },
]; ];
function ThemeSwatches({ colors }: { colors: ThemeColorSet }) {
return (
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
);
}
function ThemeChoice({
label,
colors,
selected,
onClick,
className,
}: {
label: string;
colors: ThemeColorSet;
selected: boolean;
onClick: () => void;
className?: string;
}) {
return (
<ChoiceTile
selected={selected}
onClick={onClick}
className={className}
style={{ backgroundColor: colors.base }}
>
{selected ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0" style={{ color: colors.accent }} />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{label}
</span>
<ThemeSwatches colors={colors} />
</ChoiceTile>
);
}
type SectionId = 'api' | 'theme' | 'docs' | 'account' | 'admin'; type SectionId = 'api' | 'theme' | 'docs' | 'account' | 'admin';
type SidebarSection = { type SidebarSection = {
@ -880,34 +929,13 @@ export function SettingsModal({
const colors = getThemeColors(systemTheme.id); const colors = getThemeColors(systemTheme.id);
const isActive = theme === systemTheme.id; const isActive = theme === systemTheme.id;
return ( return (
<button <ThemeChoice
label={systemTheme.name}
colors={colors}
selected={isActive}
onClick={() => setTheme(systemTheme.id)} onClick={() => setTheme(systemTheme.id)}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 w-full text-left transition duration-base ease-standard transform border className="w-full"
${isActive />
? 'border-accent'
: 'border-line hover:border-accent-line'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{systemTheme.name}
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
); );
})()} })()}
</div> </div>
@ -921,37 +949,16 @@ export function SettingsModal({
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <ThemeChoice
label="Custom"
colors={colors}
selected={isActive}
onClick={() => { onClick={() => {
setTheme('custom'); setTheme('custom');
setIsCustomExpanded(true); setIsCustomExpanded(true);
}} }}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 flex-1 text-left transition duration-base ease-standard transform border className="flex-1"
${isActive />
? 'border-accent'
: 'border-line hover:border-accent-line'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0" style={{ color: colors.accent }} />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
Custom
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
<button <button
onClick={() => setIsCustomExpanded(!isCustomExpanded)} onClick={() => setIsCustomExpanded(!isCustomExpanded)}
className="shrink-0 p-1.5 rounded-lg border border-line hover:border-accent-line transition-colors" className="shrink-0 p-1.5 rounded-lg border border-line hover:border-accent-line transition-colors"
@ -1023,35 +1030,13 @@ export function SettingsModal({
const colors = getThemeColors(t.id); const colors = getThemeColors(t.id);
const isActive = theme === t.id; const isActive = theme === t.id;
return ( return (
<button <ThemeChoice
key={t.id} key={t.id}
label={t.name}
colors={colors}
selected={isActive}
onClick={() => setTheme(t.id)} onClick={() => setTheme(t.id)}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-left transition duration-base ease-standard transform border />
${isActive
? 'border-accent'
: 'border-line hover:border-accent-line'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{t.name}
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
); );
})} })}
</div> </div>
@ -1065,35 +1050,13 @@ export function SettingsModal({
const colors = getThemeColors(t.id); const colors = getThemeColors(t.id);
const isActive = theme === t.id; const isActive = theme === t.id;
return ( return (
<button <ThemeChoice
key={t.id} key={t.id}
label={t.name}
colors={colors}
selected={isActive}
onClick={() => setTheme(t.id)} onClick={() => setTheme(t.id)}
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-left transition duration-base ease-standard transform border />
${isActive
? 'border-accent'
: 'border-line hover:border-accent-line'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-14 shrink-0"
style={{ color: colors.foreground }}
>
{t.name}
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-line" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
); );
})} })}
</div> </div>

View file

@ -18,7 +18,11 @@ import {
listboxOptionClass, listboxOptionClass,
listboxOptionsClass, listboxOptionsClass,
Button, Button,
IconButton,
Input, Input,
MenuItemClass,
cn,
menuPanelClass,
} from '@/components/ui'; } from '@/components/ui';
type ProviderType = TtsProviderId; type ProviderType = TtsProviderId;
@ -641,7 +645,9 @@ export function AdminProvidersPanel() {
</div> </div>
<Menu as="div" className="relative shrink-0"> <Menu as="div" className="relative shrink-0">
<MenuButton <MenuButton
className="inline-flex items-center justify-center h-7 w-7 rounded-md border border-offbase bg-base text-muted hover:text-accent hover:bg-offbase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50" as={IconButton}
tone="surface"
size="sm"
title="Provider actions" title="Provider actions"
aria-label="Provider actions" aria-label="Provider actions"
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending} disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
@ -659,14 +665,14 @@ export function AdminProvidersPanel() {
> >
<MenuItems <MenuItems
anchor="bottom end" anchor="bottom end"
className="z-50 mt-2 min-w-[170px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1" className={cn(menuPanelClass, 'z-50 mt-2 min-w-[170px] bg-base focus:outline-none')}
> >
<MenuItem> <MenuItem>
{({ active }) => ( {({ active }) => (
<button <button
type="button" type="button"
onClick={() => startEdit(p)} onClick={() => startEdit(p)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} className={MenuItemClass(active)}
> >
Edit Edit
</button> </button>
@ -678,7 +684,7 @@ export function AdminProvidersPanel() {
type="button" type="button"
onClick={() => setDefault(p.slug)} onClick={() => setDefault(p.slug)}
disabled={disabled} disabled={disabled}
className={`${disabled ? 'text-faint cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} className={cn(MenuItemClass(active), disabled ? 'cursor-not-allowed text-faint' : '')}
> >
Set as default Set as default
</button> </button>
@ -689,7 +695,7 @@ export function AdminProvidersPanel() {
<button <button
type="button" type="button"
onClick={() => toggleEnabled(p)} onClick={() => toggleEnabled(p)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} className={MenuItemClass(active)}
> >
{p.enabled ? 'Disable' : 'Enable'} {p.enabled ? 'Disable' : 'Enable'}
</button> </button>
@ -700,7 +706,7 @@ export function AdminProvidersPanel() {
<button <button
type="button" type="button"
onClick={() => remove(p.id)} onClick={() => remove(p.id)}
className={`${active ? 'bg-offbase' : ''} text-danger flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`} className={MenuItemClass(active, 'danger')}
> >
Delete Delete
</button> </button>

View file

@ -7,7 +7,7 @@ import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons'; import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
import { IconButton, MenuItemClass, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui'; import { IconButton, MenuItemClass, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem, menuPanelClass } from '@/components/ui';
interface FinderSidebarProps { interface FinderSidebarProps {
filter: SidebarFilter; filter: SidebarFilter;
@ -222,7 +222,7 @@ export function FinderSidebar({
> >
<MenuItems <MenuItems
anchor="bottom start" anchor="bottom start"
className="z-50 mt-2 min-w-[180px] rounded-md bg-surface shadow-elev-2 ring-1 ring-line-soft focus:outline-none p-1 normal-case tracking-normal font-normal" className={`${menuPanelClass} z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal`}
> >
<MenuItem> <MenuItem>
{({ active }) => ( {({ active }) => (

View file

@ -0,0 +1,32 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
export function ChoiceTile({
selected = false,
children,
className,
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
selected?: boolean;
children: ReactNode;
}) {
return (
<button
type={type}
aria-pressed={selected}
className={cn(
'flex items-center gap-2 rounded-lg border px-2 py-1.5 text-left',
'transition duration-base ease-standard',
focusRing,
motionColors,
selected ? 'border-accent-line' : 'border-line hover:border-accent-line',
className,
)}
{...props}
>
{children}
</button>
);
}

View file

@ -1,5 +1,6 @@
export * from './badge'; export * from './badge';
export * from './button'; export * from './button';
export * from './choice-tile';
export * from './cn'; export * from './cn';
export * from './dialog'; export * from './dialog';
export * from './divider'; export * from './divider';