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

View file

@ -50,6 +50,7 @@ import {
SidebarNavItem,
SegmentedControl,
Button,
ChoiceTile,
IconButton,
Input,
ModalFrame,
@ -109,6 +110,54 @@ const CUSTOM_COLOR_FIELDS: { key: keyof CustomThemeColors; label: string }[] = [
{ 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 SidebarSection = {
@ -880,34 +929,13 @@ export function SettingsModal({
const colors = getThemeColors(systemTheme.id);
const isActive = theme === systemTheme.id;
return (
<button
<ThemeChoice
label={systemTheme.name}
colors={colors}
selected={isActive}
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
${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>
className="w-full"
/>
);
})()}
</div>
@ -921,37 +949,16 @@ export function SettingsModal({
return (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<button
<ThemeChoice
label="Custom"
colors={colors}
selected={isActive}
onClick={() => {
setTheme('custom');
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
${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>
className="flex-1"
/>
<button
onClick={() => setIsCustomExpanded(!isCustomExpanded)}
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 isActive = theme === t.id;
return (
<button
<ThemeChoice
key={t.id}
label={t.name}
colors={colors}
selected={isActive}
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>
@ -1065,35 +1050,13 @@ export function SettingsModal({
const colors = getThemeColors(t.id);
const isActive = theme === t.id;
return (
<button
<ThemeChoice
key={t.id}
label={t.name}
colors={colors}
selected={isActive}
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>

View file

@ -18,7 +18,11 @@ import {
listboxOptionClass,
listboxOptionsClass,
Button,
IconButton,
Input,
MenuItemClass,
cn,
menuPanelClass,
} from '@/components/ui';
type ProviderType = TtsProviderId;
@ -641,7 +645,9 @@ export function AdminProvidersPanel() {
</div>
<Menu as="div" className="relative shrink-0">
<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"
aria-label="Provider actions"
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
@ -659,14 +665,14 @@ export function AdminProvidersPanel() {
>
<MenuItems
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>
{({ active }) => (
<button
type="button"
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
</button>
@ -678,7 +684,7 @@ export function AdminProvidersPanel() {
type="button"
onClick={() => setDefault(p.slug)}
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
</button>
@ -689,7 +695,7 @@ export function AdminProvidersPanel() {
<button
type="button"
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'}
</button>
@ -700,7 +706,7 @@ export function AdminProvidersPanel() {
<button
type="button"
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
</button>

View file

@ -7,7 +7,7 @@ import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
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 {
filter: SidebarFilter;
@ -222,7 +222,7 @@ export function FinderSidebar({
>
<MenuItems
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>
{({ 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 './button';
export * from './choice-tile';
export * from './cn';
export * from './dialog';
export * from './divider';