Converge sidebar and reader controls

This commit is contained in:
Richard R 2026-06-01 05:08:06 -06:00
parent cc0d460f6b
commit 9038562971
12 changed files with 291 additions and 227 deletions

View file

@ -52,14 +52,15 @@ import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import {
SidebarNav,
SidebarNavItem,
SegmentedControl,
inputClass,
buttonClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
segmentedButtonClass,
segmentedGroupClass,
} from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
} from '@/components/ui';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { fetchChangelogManifest, fetchChangelogReleaseBody } from '@/lib/client/changelog';
@ -572,50 +573,42 @@ export function SettingsModal({
/>
) : (
<>
{/* Mobile: 2x2 grid nav */}
<div className="grid grid-cols-2 gap-1 sm:hidden border-b border-line-soft bg-background p-2">
{/* Mobile nav */}
<SidebarNav layout="grid" className="sm:hidden border-b border-line-soft bg-background p-2">
{visibleSections.map((section) => {
const Icon = section.icon;
return (
<button
<SidebarNavItem
compact
key={section.id}
onClick={() => setActiveSection(section.id)}
className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors ${
activeSection === section.id
? 'bg-accent text-background'
: 'text-foreground hover:bg-accent-wash hover:text-accent'
}`}
>
<Icon className="w-3.5 h-3.5" />
{section.label}
</button>
active={activeSection === section.id}
icon={<Icon className="w-3.5 h-3.5" />}
label={section.label}
/>
);
})}
</div>
</SidebarNav>
<div className="flex flex-row h-[490px]">
{/* Desktop: vertical sidebar */}
<nav className="hidden sm:block w-44 shrink-0 border-r border-line-soft bg-background p-2">
<div className="flex flex-col gap-1">
<SidebarNav>
{visibleSections.map((section) => {
const Icon = section.icon;
const active = activeSection === section.id;
return (
<button
<SidebarNavItem
key={section.id}
onClick={() => setActiveSection(section.id)}
className={`w-full flex items-center gap-2.5 text-left px-2.5 py-1.5 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
active
? 'bg-accent text-background'
: 'text-foreground hover:bg-surface hover:text-accent'
}`}
>
<Icon className="w-4 h-4 shrink-0" />
{section.label}
</button>
active={active}
icon={<Icon className="w-4 h-4" />}
label={section.label}
className="whitespace-nowrap"
/>
);
})}
</div>
</SidebarNav>
</nav>
{/* Content */}
@ -1166,30 +1159,16 @@ export function SettingsModal({
{/* Admin Section */}
{activeSection === 'admin' && isAdmin && (
<div className="space-y-4">
<div
role="radiogroup"
aria-label="Admin tab"
className={`${segmentedGroupClass} grid-cols-2`}
>
{([
{ id: 'providers', label: 'Shared providers' },
{ id: 'features', label: 'Site features' },
] as { id: AdminSubTab; label: string }[]).map((tab) => {
const active = adminSubTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setAdminSubTab(tab.id)}
className={segmentedButtonClass(active)}
>
{tab.label}
</button>
);
})}
</div>
<SegmentedControl
value={adminSubTab}
options={[
{ value: 'providers', label: 'Shared providers' },
{ value: 'features', label: 'Site features' },
]}
onChange={setAdminSubTab}
ariaLabel="Admin tab"
className="grid-cols-2"
/>
{adminSubTab === 'providers' && <AdminProvidersPanel />}
{adminSubTab === 'features' && <AdminFeaturesPanel />}
</div>

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 } from '@/components/ui';
import { IconButton, MenuItemClass, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
interface FinderSidebarProps {
filter: SidebarFilter;
@ -31,60 +31,6 @@ interface FinderSidebarProps {
const MIN_WIDTH = 168;
const MAX_WIDTH = 320;
interface SidebarRowProps {
active: boolean;
onClick: () => void;
icon: ReactNode;
label: string;
count?: number;
countClassName?: string;
trailing?: ReactNode;
isDropTarget?: boolean;
}
function SidebarRow({
active,
onClick,
icon,
label,
count,
countClassName,
trailing,
isDropTarget,
}: SidebarRowProps) {
return (
<button
type="button"
onClick={onClick}
className={
'group w-full flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition duration-base ease-standard text-left ' +
(active
? 'border-accent-line bg-surface-sunken text-accent'
: 'border-transparent bg-transparent text-foreground hover:border-accent-line hover:text-accent') +
(isDropTarget ? ' ring-1 ring-accent-line' : '')
}
>
<span
className={
'w-4 h-4 shrink-0 flex items-center justify-center transition-colors duration-base ' +
(active ? 'text-accent' : 'text-soft group-hover:text-accent')
}
>
{icon}
</span>
<span className="truncate flex-1">{label}</span>
{typeof count === 'number' && count > 0 && (
<span
className={`text-[10px] text-soft tabular-nums transition-transform duration-base ease-standard ${countClassName ?? ''}`}
>
{count}
</span>
)}
{trailing}
</button>
);
}
function FolderRow({
folder,
active,
@ -123,7 +69,8 @@ function FolderRow({
ref={dropRef as unknown as React.RefObject<HTMLDivElement>}
className="group/folder relative"
>
<SidebarRow
<SidebarNavItem
compact
active={active}
onClick={onClick}
icon={<FolderIcon className="w-3.5 h-3.5" />}
@ -150,28 +97,6 @@ function FolderRow({
);
}
function SectionHeader({
children,
isFirst,
rightSlot,
}: {
children: ReactNode;
isFirst?: boolean;
rightSlot?: ReactNode;
}) {
return (
<div
className={
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-soft font-semibold leading-none flex items-center justify-between ' +
(isFirst ? 'pt-1.5' : 'pt-3')
}
>
<span>{children}</span>
{rightSlot && <span className="inline-flex items-center leading-none shrink-0">{rightSlot}</span>}
</div>
);
}
export function FinderSidebar({
filter,
onFilterChange,
@ -210,14 +135,15 @@ export function FinderSidebar({
className="relative h-full w-full md:[width:var(--sidebar-width)] rounded-none border-y-0 border-l-0 border-r border-line-soft shadow-none shrink-0 flex flex-col"
>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="p-2 flex flex-col gap-0.5">
<SidebarNav className="p-2">
{topSlot && (
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{topSlot}
</div>
)}
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
<SidebarRow
<SidebarNavGroup isFirst={!!topSlot}>Library</SidebarNavGroup>
<SidebarNavItem
compact
active={filter === 'all'}
onClick={() => {
onFilterChange('all');
@ -227,7 +153,8 @@ export function FinderSidebar({
label="All Documents"
count={counts.all}
/>
<SidebarRow
<SidebarNavItem
compact
active={filter === 'recents'}
onClick={() => {
onFilterChange('recents');
@ -237,8 +164,9 @@ export function FinderSidebar({
label="Recently Opened"
/>
<SectionHeader>Kinds</SectionHeader>
<SidebarRow
<SidebarNavGroup>Kinds</SidebarNavGroup>
<SidebarNavItem
compact
active={filter === 'pdf'}
onClick={() => {
onFilterChange('pdf');
@ -248,7 +176,8 @@ export function FinderSidebar({
label="PDF"
count={counts.pdf}
/>
<SidebarRow
<SidebarNavItem
compact
active={filter === 'epub'}
onClick={() => {
onFilterChange('epub');
@ -258,7 +187,8 @@ export function FinderSidebar({
label="EPUB"
count={counts.epub}
/>
<SidebarRow
<SidebarNavItem
compact
active={filter === 'html'}
onClick={() => {
onFilterChange('html');
@ -269,8 +199,8 @@ export function FinderSidebar({
count={counts.html}
/>
<SectionHeader
rightSlot={(
<SidebarNavGroup
action={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton
as={IconButton}
@ -333,7 +263,7 @@ export function FinderSidebar({
)}
>
Folders
</SectionHeader>
</SidebarNavGroup>
{folders.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-soft">No folders yet</p>
) : (
@ -354,7 +284,7 @@ export function FinderSidebar({
/>
))
)}
</div>
</SidebarNav>
</div>
{bottomSlot && (
<div

View file

@ -10,7 +10,7 @@ import {
HamburgerIcon,
} from './finderIcons';
import { ChevronUpDownIcon } from '@/components/icons/Icons';
import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, toolbarButtonStyles } from '@/components/ui';
import { SearchField, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, listboxOptionClass, listboxOptionsClass, toolbarButtonStyles } from '@/components/ui';
import type { ReactNode } from 'react';
interface FinderToolbarProps {
@ -151,19 +151,12 @@ export function FinderToolbar({
<span>{currentSort.label}</span>
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
</ListboxButton>
<ListboxOptions
anchor="bottom end"
className="z-50 mt-1 rounded-md bg-surface-sunken border border-line shadow-elev-2 p-1 focus:outline-none"
>
<ListboxOptions anchor="bottom end" className={listboxOptionsClass}>
{SORT_OPTIONS.map((opt) => (
<ListboxOption
key={opt.value}
value={opt.value}
className={({ active, selected }) =>
`cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${
active ? 'bg-surface-sunken text-accent' : 'text-foreground'
} ${selected ? 'font-semibold' : ''}`
}
className={({ active, selected }) => listboxOptionClass(active || selected)}
>
{opt.label}
</ListboxOption>
@ -175,16 +168,13 @@ export function FinderToolbar({
<div className="flex-1 min-w-0" />
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface-sunken border border-line hover:border-accent-line focus-within:ring-1 focus-within:ring-accent-line focus-within:border-accent-line transition-colors duration-base ease-standard w-[160px] md:w-[200px]">
<SearchIcon className="w-3.5 h-3.5 text-soft shrink-0" />
<input
type="search"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search"
className="flex-1 min-w-0 bg-transparent outline-none text-xs text-foreground placeholder:text-soft"
/>
</div>
<SearchField
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search"
className="hidden w-[160px] md:w-[200px] sm:flex"
icon={<SearchIcon className="w-3.5 h-3.5" />}
/>
{rightSlot && (
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-line ml-0.5">

View file

@ -5,6 +5,7 @@ import { Fragment } from 'react';
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
import { ZoomControl } from '@/components/documents/ZoomControl';
import { UserMenu } from '@/components/auth/UserMenu';
import { IconButton, MenuItemClass, ToolbarButton, menuPanelClass } from '@/components/ui';
interface DocumentHeaderMenuProps {
zoomLevel: number;
@ -47,45 +48,33 @@ export function DocumentHeaderMenu({
max={maxZoom}
/>
{onOpenSegments && (
<button
<ToolbarButton
onClick={onOpenSegments}
className={`inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard ${
isSegmentsOpen
? 'border-accent text-accent bg-surface-sunken'
: 'border-line text-foreground hover:bg-accent-wash hover:text-accent'
}`}
active={isSegmentsOpen}
aria-label={isSegmentsOpen ? 'Hide segments sidebar' : 'Open segments sidebar'}
title={isSegmentsOpen ? 'Hide Segments' : 'Segments'}
>
<ListIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
</button>
</ToolbarButton>
)}
{showAudiobookExport && onOpenAudiobook && (
<button
<ToolbarButton
onClick={onOpenAudiobook}
className={`inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard ${
isAudiobookOpen
? 'border-accent text-accent bg-surface-sunken'
: 'border-line text-foreground hover:bg-accent-wash hover:text-accent'
}`}
active={isAudiobookOpen}
aria-label={isAudiobookOpen ? 'Hide audiobook export' : 'Open audiobook export'}
title={isAudiobookOpen ? 'Hide Export Audiobook' : 'Export Audiobook'}
>
<DownloadIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
</button>
</ToolbarButton>
)}
<button
<ToolbarButton
onClick={onOpenSettings}
className={`inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard ${
isSettingsOpen
? 'border-accent text-accent bg-surface-sunken'
: 'border-line text-foreground hover:bg-accent-wash hover:text-accent'
}`}
active={isSettingsOpen}
aria-label={isSettingsOpen ? 'Hide settings' : 'Open settings'}
title={isSettingsOpen ? 'Hide Settings' : 'Settings'}
>
<FileSettingsIcon className="w-4 h-4 transform transition-transform duration-base ease-standard" />
</button>
</ToolbarButton>
<UserMenu />
</div>
);
@ -95,7 +84,9 @@ export function DocumentHeaderMenu({
<div className="sm:hidden flex items-center">
<Menu as="div" className="relative inline-block text-left">
<MenuButton
className="inline-flex items-center justify-center py-1 px-2 rounded-md border border-line bg-surface text-foreground text-xs hover:bg-accent-wash transition duration-base ease-standard hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
as={IconButton}
tone="surface"
size="sm"
title="Menu"
>
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
@ -109,7 +100,7 @@ export function DocumentHeaderMenu({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems className="absolute right-0 mt-2 min-w-max origin-top-right divide-y divide-line-soft rounded-md bg-surface shadow-elev-2 ring-1 ring-line-soft focus:outline-none z-50">
<MenuItems className={`${menuPanelClass} absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none`}>
{/* Zoom Controls Section */}
<div className="px-4 py-3">
<p className="text-xs font-medium text-soft mb-2">Zoom / Padding</p>
@ -138,8 +129,7 @@ export function DocumentHeaderMenu({
{({ active }) => (
<button
onClick={onOpenSegments}
className={`${active || isSegmentsOpen ? 'bg-surface-sunken text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
className={MenuItemClass(active || isSegmentsOpen)}
>
<ListIcon className="h-4 w-4" />
{isSegmentsOpen ? 'Hide Segments' : 'Segments'}
@ -152,8 +142,7 @@ export function DocumentHeaderMenu({
{({ active }) => (
<button
onClick={onOpenAudiobook}
className={`${active || isAudiobookOpen ? 'bg-surface-sunken text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
className={MenuItemClass(active || isAudiobookOpen)}
>
<DownloadIcon className="h-4 w-4" />
{isAudiobookOpen ? 'Hide Audiobook' : 'Export Audiobook'}
@ -165,8 +154,7 @@ export function DocumentHeaderMenu({
{({ active }) => (
<button
onClick={onOpenSettings}
className={`${active || isSettingsOpen ? 'bg-surface-sunken text-accent' : 'text-foreground'
} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
className={MenuItemClass(active || isSettingsOpen)}
>
<FileSettingsIcon className="h-4 w-4" />
{isSettingsOpen ? 'Hide Settings' : 'Settings'}

View file

@ -15,8 +15,7 @@ import {
clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength,
} from '@/types/config';
import { Section, ToggleRow, CheckItem, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
import { buttonClass } from '@/components/ui/buttonPrimitives';
import { Section, ToggleRow, CheckItem, SegmentedControl, buttonClass, rangeInputClass } from '@/components/ui';
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
@ -51,8 +50,6 @@ const viewTypeTextMapping = [
{ id: 'scroll', name: 'Continuous Scroll' },
];
const rangeInputClassName = 'w-full bg-offbase rounded-md appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-md [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-md [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent';
type RangeSettingProps = {
label: string;
value: number;
@ -87,7 +84,7 @@ function RangeSetting({
step={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className={`flex-1 ${rangeInputClassName}`}
className={`flex-1 ${rangeInputClass}`}
/>
<span className={`${valueWidth} text-xs font-semibold text-right text-foreground`}>{formatter(value)}</span>
</div>
@ -163,27 +160,13 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
>
<div className="space-y-1.5">
<label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label>
<div
role="radiogroup"
aria-label="Page mode"
className={`${segmentedGroupClass} grid-cols-3`}
>
{viewTypeTextMapping.map((view) => {
const active = selectedView.id === view.id;
return (
<button
key={view.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => updateConfigKey('viewType', view.id as ViewType)}
className={segmentedButtonClass(active)}
>
{view.name}
</button>
);
})}
</div>
<SegmentedControl
value={selectedView.id as ViewType}
options={viewTypeTextMapping.map((view) => ({ value: view.id as ViewType, label: view.name }))}
onChange={(nextViewType) => updateConfigKey('viewType', nextViewType)}
ariaLabel="Page mode"
className="grid-cols-3"
/>
{selectedView.id === 'scroll' ? (
<p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p>
) : null}

View file

@ -10,9 +10,12 @@ export * from './input';
export * from './menu';
export * from './popover';
export * from './range';
export * from './search-field';
export * from './section';
export * from './select';
export * from './segmented-control';
export * from './sidebar';
export * from './sidebar-nav';
export * from './surface';
export * from './switch';
export * from './tokens';

View file

@ -1,13 +1,15 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
export const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
export function Menu({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={cn('rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft', className)} {...props}>
<div className={cn(menuPanelClass, className)} {...props}>
{children}
</div>
);

View file

@ -7,3 +7,7 @@ export const rangeInputClass = cn(
'[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-lg [&::-moz-range-track]:bg-surface-sunken',
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-accent',
);
export function rangeInputClassName(className?: string) {
return cn(rangeInputClass, className);
}

View file

@ -0,0 +1,32 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
export function SearchField({
icon,
className,
inputClassName,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> & {
icon?: ReactNode;
inputClassName?: string;
}) {
return (
<label
className={cn(
'flex min-w-0 items-center gap-1.5 rounded-md border border-line bg-surface-sunken px-2 py-1',
'focus-within:border-accent-line focus-within:ring-1 focus-within:ring-accent-line hover:border-accent-line',
focusRing,
motionColors,
className,
)}
>
{icon ? <span className="shrink-0 text-soft">{icon}</span> : null}
<input
type="search"
className={cn('min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none placeholder:text-soft', inputClassName)}
{...props}
/>
</label>
);
}

View file

@ -0,0 +1,47 @@
import type { ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
export const segmentedGroupClass = 'grid gap-1 rounded-full border border-line bg-surface-sunken p-1';
export const segmentedButtonClass = (active: boolean) =>
cn(
'rounded-full px-2.5 py-1.5 text-xs font-medium',
focusRing,
motionColors,
active ? 'bg-accent text-background' : 'text-soft hover:bg-accent-wash hover:text-foreground',
);
export function SegmentedControl<T extends string>({
value,
options,
onChange,
ariaLabel,
className,
}: {
value: T;
options: Array<{ value: T; label: ReactNode }>;
onChange: (value: T) => void;
ariaLabel: string;
className?: string;
}) {
return (
<div role="radiogroup" aria-label={ariaLabel} className={cn(segmentedGroupClass, className)}>
{options.map((option) => {
const active = value === option.value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(option.value)}
className={segmentedButtonClass(active)}
>
{option.label}
</button>
);
})}
</div>
);
}

View file

@ -2,6 +2,7 @@
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
import { cn } from './cn';
export { segmentedButtonClass, segmentedGroupClass } from './segmented-control';
export const listboxButtonClass =
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line hover:bg-accent-wash transition-colors duration-fast ease-standard';
@ -12,14 +13,6 @@ export const listboxOptionsClass =
export const listboxOptionClass = (active: boolean) =>
cn('relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm', active ? 'bg-accent-wash text-foreground' : 'text-foreground');
export const segmentedGroupClass = 'grid gap-1 rounded-full border border-line bg-surface-sunken p-1';
export const segmentedButtonClass = (active: boolean) =>
cn(
'rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors duration-fast ease-standard focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
active ? 'bg-accent text-background' : 'text-soft hover:bg-accent-wash hover:text-foreground',
);
export function Select({
value,
onChange,

View file

@ -0,0 +1,113 @@
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
import { cn } from './cn';
import { focusRing, motionColors } from './tokens';
export function SidebarNav({
children,
className,
layout = 'stack',
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
layout?: 'stack' | 'grid';
}) {
return (
<div
className={cn(
layout === 'grid' ? 'grid grid-cols-2 gap-1' : 'flex flex-col gap-0.5',
className,
)}
{...props}
>
{children}
</div>
);
}
export function SidebarNavGroup({
children,
action,
className,
isFirst = false,
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
action?: ReactNode;
isFirst?: boolean;
}) {
return (
<div
className={cn(
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-soft font-semibold leading-none flex items-center justify-between',
isFirst ? 'pt-1.5' : 'pt-3',
className,
)}
{...props}
>
<span>{children}</span>
{action ? <span className="inline-flex shrink-0 items-center leading-none">{action}</span> : null}
</div>
);
}
export function SidebarNavItem({
active = false,
icon,
label,
count,
countClassName,
trailing,
isDropTarget = false,
className,
compact = false,
type = 'button',
children,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
active?: boolean;
icon?: ReactNode;
label?: ReactNode;
count?: number;
countClassName?: string;
trailing?: ReactNode;
isDropTarget?: boolean;
compact?: boolean;
}) {
return (
<button
type={type}
className={cn(
'group w-full min-w-0 border text-left font-medium',
'inline-flex items-center transition duration-base ease-standard',
focusRing,
motionColors,
compact ? 'gap-1.5 rounded-md px-2 py-1 text-xs' : 'gap-2 rounded-md px-2.5 py-1.5 text-sm',
active
? 'border-accent-line bg-surface-sunken text-accent'
: 'border-transparent bg-transparent text-foreground hover:border-accent-line hover:bg-accent-wash hover:text-accent',
isDropTarget ? 'ring-1 ring-accent-line' : '',
className,
)}
{...props}
>
{icon ? (
<span
className={cn(
'shrink-0 inline-flex items-center justify-center transition-colors duration-base',
compact ? 'h-4 w-4' : 'h-5 w-5',
active ? 'text-accent' : 'text-soft group-hover:text-accent',
)}
>
{icon}
</span>
) : null}
{label ?? children ? <span className="min-w-0 flex-1 truncate">{label ?? children}</span> : null}
{typeof count === 'number' && count > 0 ? (
<span className={cn('text-[10px] text-soft tabular-nums transition-transform duration-base ease-standard', countClassName)}>
{count}
</span>
) : null}
{trailing}
</button>
);
}