Refactor UI components to use consistent button and icon styles across the application
This commit is contained in:
parent
fdceed6067
commit
4f1d8feda5
12 changed files with 233 additions and 87 deletions
|
|
@ -176,7 +176,7 @@ export default function EPUBPage() {
|
||||||
href="/app"
|
href="/app"
|
||||||
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
|
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 text-soft" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" 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>
|
||||||
Back to Documents
|
Back to Documents
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,7 @@ export default function PDFViewerPage() {
|
||||||
onClick={handleBackToDocuments}
|
onClick={handleBackToDocuments}
|
||||||
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
|
className={buttonClass({ variant: 'secondary', size: 'md', className: 'gap-2' })}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 text-soft" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" 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>
|
||||||
Back to Documents
|
Back to Documents
|
||||||
|
|
@ -346,7 +346,7 @@ export default function PDFViewerPage() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={requestForceReparse}
|
onClick={requestForceReparse}
|
||||||
className="inline-flex items-center rounded-md border border-line bg-surface px-3 py-1.5 text-xs font-medium text-foreground hover:text-accent transition-colors"
|
className={buttonClass({ variant: 'secondary', size: 'sm' })}
|
||||||
>
|
>
|
||||||
Retry Parse
|
Retry Parse
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
||||||
size="xs"
|
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 transform transition-transform duration-base ease-standard" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<input
|
<input
|
||||||
ref={nativeRef}
|
ref={nativeRef}
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ export function HomeContent() {
|
||||||
const appActions = (
|
const appActions = (
|
||||||
<div className="flex flex-col gap-0.5 w-full">
|
<div className="flex flex-col gap-0.5 w-full">
|
||||||
<SettingsTrigger
|
<SettingsTrigger
|
||||||
|
variant="sidebar"
|
||||||
triggerLabel="Settings"
|
triggerLabel="Settings"
|
||||||
className="w-full justify-start gap-2 px-2 py-1 text-[12px] border-transparent hover:border-accent"
|
|
||||||
onOpen={() => setSettingsOpen(true)}
|
onOpen={() => setSettingsOpen(true)}
|
||||||
/>
|
/>
|
||||||
<UserMenu variant="sidebar" />
|
<UserMenu variant="sidebar" />
|
||||||
|
|
|
||||||
|
|
@ -181,12 +181,27 @@ type AdminSubTab = 'providers' | 'features';
|
||||||
export function SettingsTrigger({
|
export function SettingsTrigger({
|
||||||
className = '',
|
className = '',
|
||||||
triggerLabel,
|
triggerLabel,
|
||||||
|
variant = 'button',
|
||||||
onOpen,
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
triggerLabel?: string;
|
triggerLabel?: string;
|
||||||
|
variant?: 'button' | 'sidebar';
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
if (variant === 'sidebar') {
|
||||||
|
return (
|
||||||
|
<SidebarNavItem
|
||||||
|
compact
|
||||||
|
onClick={onOpen}
|
||||||
|
className={className}
|
||||||
|
aria-label="Settings"
|
||||||
|
icon={<SettingsIcon className="w-3.5 h-3.5" />}
|
||||||
|
label={triggerLabel ?? 'Settings'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
@ -959,16 +974,18 @@ export function SettingsModal({
|
||||||
}}
|
}}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<button
|
<IconButton
|
||||||
onClick={() => setIsCustomExpanded(!isCustomExpanded)}
|
onClick={() => setIsCustomExpanded(!isCustomExpanded)}
|
||||||
className="shrink-0 p-1.5 rounded-lg border border-line hover:border-accent-line transition-colors"
|
tone="surface"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
style={{ color: colors.muted, backgroundColor: colors.base }}
|
style={{ color: colors.muted, backgroundColor: colors.base }}
|
||||||
aria-label={isCustomExpanded ? 'Collapse color picker' : 'Expand color picker'}
|
aria-label={isCustomExpanded ? 'Collapse color picker' : 'Expand color picker'}
|
||||||
>
|
>
|
||||||
<svg className={`w-3.5 h-3.5 transition-transform duration-base ${isCustomExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className={`w-3.5 h-3.5 transition-transform duration-base ${isCustomExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isCustomExpanded && (
|
{isCustomExpanded && (
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { getAuthClient } from '@/lib/client/auth-client';
|
import { getAuthClient } from '@/lib/client/auth-client';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { UserIcon } from '@/components/icons/Icons';
|
import { UserIcon } from '@/components/icons/Icons';
|
||||||
import { IconButton, buttonClass } from '@/components/ui';
|
import { IconButton, SidebarNavItem, SidebarNavLink, buttonClass } from '@/components/ui';
|
||||||
|
|
||||||
type UserMenuVariant = 'toolbar' | 'sidebar';
|
type UserMenuVariant = 'toolbar' | 'sidebar';
|
||||||
|
|
||||||
|
|
@ -31,21 +31,24 @@ export function UserMenu({
|
||||||
router.push('/signin');
|
router.push('/signin');
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowClass =
|
|
||||||
'w-full inline-flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border border-transparent transition duration-base ease-standard text-left hover:border-accent hover:bg-accent-wash hover:text-accent';
|
|
||||||
|
|
||||||
if (!session || session.user.isAnonymous) {
|
if (!session || session.user.isAnonymous) {
|
||||||
if (variant === 'sidebar') {
|
if (variant === 'sidebar') {
|
||||||
return (
|
return (
|
||||||
<div className={`flex w-full flex-col gap-0.5 ${className}`}>
|
<div className={`flex w-full flex-col gap-0.5 ${className}`}>
|
||||||
<Link href="/signin" className={rowClass}>
|
<Link href="/signin" legacyBehavior passHref>
|
||||||
<UserIcon className="h-3.5 w-3.5 text-soft" />
|
<SidebarNavLink
|
||||||
<span className="truncate">Connect</span>
|
compact
|
||||||
|
icon={<UserIcon className="h-3.5 w-3.5" />}
|
||||||
|
label="Connect"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
{enableUserSignups && (
|
{enableUserSignups && (
|
||||||
<Link href="/signup" className={rowClass}>
|
<Link href="/signup" legacyBehavior passHref>
|
||||||
<UserIcon className="h-3.5 w-3.5 text-soft" />
|
<SidebarNavLink
|
||||||
<span className="truncate">Create account</span>
|
compact
|
||||||
|
icon={<UserIcon className="h-3.5 w-3.5" />}
|
||||||
|
label="Create account"
|
||||||
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -72,20 +75,24 @@ export function UserMenu({
|
||||||
|
|
||||||
if (variant === 'sidebar') {
|
if (variant === 'sidebar') {
|
||||||
return (
|
return (
|
||||||
<button
|
<SidebarNavItem
|
||||||
|
compact
|
||||||
onClick={handleDisconnectAccount}
|
onClick={handleDisconnectAccount}
|
||||||
className={`${rowClass} ${className}`}
|
className={className}
|
||||||
title="Disconnect account"
|
title="Disconnect account"
|
||||||
aria-label="Disconnect account"
|
aria-label="Disconnect account"
|
||||||
|
icon={<UserIcon className="h-3.5 w-3.5" />}
|
||||||
|
label={session.user.email || 'Account'}
|
||||||
|
trailing={(
|
||||||
|
<svg className="h-3.5 w-3.5 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
||||||
|
<polyline points="16 17 21 12 16 7"></polyline>
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12"></line>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<UserIcon className="h-3.5 w-3.5 text-soft" />
|
{session.user.email || 'Account'}
|
||||||
<span className="truncate flex-1">{session.user.email || 'Account'}</span>
|
</SidebarNavItem>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
|
|
||||||
<polyline points="16 17 21 12 16 7"></polyline>
|
|
||||||
<line x1="21" y1="12" x2="9" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,16 @@ function SidebarUploadLoader({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DocumentListStateLoader() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-full w-full min-h-0 bg-surface-sunken animate-pulse"
|
||||||
|
aria-label="Loading documents"
|
||||||
|
aria-busy="true"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
const cachedState = cachedDocumentListState;
|
const cachedState = cachedDocumentListState;
|
||||||
const [sortBy, setSortBy] = useState<SortBy>(cachedState?.sortBy ?? DEFAULT_STATE.sortBy);
|
const [sortBy, setSortBy] = useState<SortBy>(cachedState?.sortBy ?? DEFAULT_STATE.sortBy);
|
||||||
|
|
@ -670,7 +680,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
<DocumentListSkeleton viewMode={fallbackViewMode} iconSize={iconSize} />
|
{isInitialized ? (
|
||||||
|
<DocumentListSkeleton viewMode={fallbackViewMode} iconSize={iconSize} />
|
||||||
|
) : (
|
||||||
|
<DocumentListStateLoader />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : allDocuments.length === 0 ? (
|
) : allDocuments.length === 0 ? (
|
||||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||||
|
|
|
||||||
|
|
@ -79,20 +79,20 @@ function FolderRow({
|
||||||
countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6"
|
countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6"
|
||||||
isDropTarget={isDropTarget}
|
isDropTarget={isDropTarget}
|
||||||
/>
|
/>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete();
|
onDelete();
|
||||||
}}
|
}}
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-5 w-5 inline-flex items-center justify-center rounded text-soft opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100 hover:text-accent hover:bg-accent-wash transition"
|
size="xs"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100"
|
||||||
aria-label={`Delete ${folder.name}`}
|
aria-label={`Delete ${folder.name}`}
|
||||||
title={`Delete ${folder.name}`}
|
title={`Delete ${folder.name}`}
|
||||||
>
|
>
|
||||||
<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="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
HamburgerIcon,
|
HamburgerIcon,
|
||||||
} from './finderIcons';
|
} from './finderIcons';
|
||||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||||
import { SearchField, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, listboxOptionClass, listboxOptionsClass, toolbarButtonStyles } from '@/components/ui';
|
import { SearchField, Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, listboxCompactOptionClass, listboxCompactOptionsClass, toolbarButtonStyles } from '@/components/ui';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface FinderToolbarProps {
|
interface FinderToolbarProps {
|
||||||
|
|
@ -146,17 +146,17 @@ export function FinderToolbar({
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||||
<ListboxButton
|
<ListboxButton
|
||||||
className={toolbarButtonStyles({ className: 'gap-1 min-w-[90px] justify-between' })}
|
className={toolbarButtonStyles({ className: 'gap-1 min-w-[86px] justify-between' })}
|
||||||
>
|
>
|
||||||
<span>{currentSort.label}</span>
|
<span>{currentSort.label}</span>
|
||||||
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
|
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
|
||||||
</ListboxButton>
|
</ListboxButton>
|
||||||
<ListboxOptions anchor="bottom end" className={listboxOptionsClass}>
|
<ListboxOptions anchor="bottom end" className={listboxCompactOptionsClass}>
|
||||||
{SORT_OPTIONS.map((opt) => (
|
{SORT_OPTIONS.map((opt) => (
|
||||||
<ListboxOption
|
<ListboxOption
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
value={opt.value}
|
value={opt.value}
|
||||||
className={({ active, selected }) => listboxOptionClass(active || selected)}
|
className={({ active, selected }) => listboxCompactOptionClass(active, selected)}
|
||||||
>
|
>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</ListboxOption>
|
</ListboxOption>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import toast from 'react-hot-toast';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
||||||
import { buttonClass } from '@/components/ui';
|
import { Button, IconButton, popoverPanelClass } from '@/components/ui';
|
||||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||||||
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
||||||
|
|
@ -632,33 +632,27 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
||||||
|
|
||||||
const headerActions = (
|
const headerActions = (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
onClick={() => void handleClearCache()}
|
onClick={() => void handleClearCache()}
|
||||||
aria-label="Clear segments cache"
|
aria-label="Clear segments cache"
|
||||||
title="Clear cache for listed segments"
|
title="Clear cache for listed segments"
|
||||||
disabled={isClearingSegments}
|
disabled={isClearingSegments}
|
||||||
className={buttonClass({
|
variant="secondary"
|
||||||
variant: 'secondary',
|
size="xs"
|
||||||
size: 'xs',
|
className="h-8 px-2 text-soft"
|
||||||
className: 'h-8 px-2 text-soft',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{isClearingSegments ? 'Clearing…' : 'Clear'}
|
{isClearingSegments ? 'Clearing…' : 'Clear'}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
aria-label="Refresh segments"
|
aria-label="Refresh segments"
|
||||||
title="Refresh"
|
title="Refresh"
|
||||||
className={buttonClass({
|
tone="surface"
|
||||||
variant: 'secondary',
|
size="md"
|
||||||
size: 'icon',
|
className="h-8 w-8 text-soft"
|
||||||
className: 'h-8 w-8 text-soft',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<RefreshIcon className="w-3.5 h-3.5" />
|
<RefreshIcon className="w-3.5 h-3.5" />
|
||||||
</button>
|
</IconButton>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -893,9 +887,10 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
||||||
return (
|
return (
|
||||||
<Popover className="relative shrink-0">
|
<Popover className="relative shrink-0">
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
|
as={IconButton}
|
||||||
|
size="sm"
|
||||||
aria-label="Segment metadata"
|
aria-label="Segment metadata"
|
||||||
title="Metadata"
|
title="Metadata"
|
||||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md border border-transparent text-soft hover:bg-accent-wash hover:border-line hover:text-accent transition-colors"
|
|
||||||
>
|
>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
|
|
@ -910,7 +905,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
||||||
>
|
>
|
||||||
<PopoverPanel
|
<PopoverPanel
|
||||||
anchor="bottom end"
|
anchor="bottom end"
|
||||||
className="z-[60] w-[300px] mt-1 rounded-lg border border-line bg-surface shadow-elev-3 p-3"
|
className={`${popoverPanelClass} z-[60] w-[300px] mt-1`}
|
||||||
>
|
>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
<Row label="locator">
|
<Row label="locator">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ export const listboxPanelClass =
|
||||||
export const listboxOptionsClass =
|
export const listboxOptionsClass =
|
||||||
cn(listboxPanelClass, 'w-[var(--button-width)] [--anchor-gap:0.25rem]');
|
cn(listboxPanelClass, 'w-[var(--button-width)] [--anchor-gap:0.25rem]');
|
||||||
|
|
||||||
|
export const listboxCompactOptionsClass =
|
||||||
|
'z-50 min-w-[8rem] rounded-md bg-surface p-1 shadow-elev-2 ring-1 ring-line focus:outline-none [--anchor-gap:0.25rem]';
|
||||||
|
|
||||||
export const listboxOptionClass = (active: boolean, selected = false, inset: 'check' | 'none' = 'check') =>
|
export const listboxOptionClass = (active: boolean, selected = false, inset: 'check' | 'none' = 'check') =>
|
||||||
cn(
|
cn(
|
||||||
'relative cursor-pointer select-none rounded-sm py-1.5 text-sm',
|
'relative cursor-pointer select-none rounded-sm py-1.5 text-sm',
|
||||||
|
|
@ -20,6 +23,16 @@ export const listboxOptionClass = (active: boolean, selected = false, inset: 'ch
|
||||||
selected ? 'bg-accent text-background font-medium' : active ? 'bg-accent-wash text-foreground' : 'text-foreground',
|
selected ? 'bg-accent text-background font-medium' : active ? 'bg-accent-wash text-foreground' : 'text-foreground',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const listboxCompactOptionClass = (active: boolean, selected = false) =>
|
||||||
|
cn(
|
||||||
|
'relative cursor-pointer select-none rounded-sm px-2 py-1 text-xs',
|
||||||
|
active
|
||||||
|
? 'bg-accent-wash text-accent'
|
||||||
|
: selected
|
||||||
|
? 'bg-surface-sunken text-accent font-medium'
|
||||||
|
: 'text-foreground',
|
||||||
|
);
|
||||||
|
|
||||||
export function Select({
|
export function Select({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
|
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
import { focusRing, motionColors } from './tokens';
|
import { focusRing, motionColors } from './tokens';
|
||||||
|
|
||||||
|
|
@ -50,6 +50,82 @@ export function SidebarNavGroup({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sidebarNavItemClass({
|
||||||
|
active = false,
|
||||||
|
compact = false,
|
||||||
|
isDropTarget = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
isDropTarget?: boolean;
|
||||||
|
className?: string;
|
||||||
|
} = {}) {
|
||||||
|
return 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sidebarNavIconClass({
|
||||||
|
active = false,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
return 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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarNavItemContent({
|
||||||
|
active = false,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
countClassName,
|
||||||
|
trailing,
|
||||||
|
compact = false,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
label?: ReactNode;
|
||||||
|
count?: number;
|
||||||
|
countClassName?: string;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
compact?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{icon ? (
|
||||||
|
<span className={sidebarNavIconClass({ active, compact })}>
|
||||||
|
{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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SidebarNavItem({
|
export function SidebarNavItem({
|
||||||
active = false,
|
active = false,
|
||||||
icon,
|
icon,
|
||||||
|
|
@ -76,38 +152,62 @@ export function SidebarNavItem({
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={sidebarNavItemClass({ active, compact, isDropTarget, className })}
|
||||||
'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}
|
{...props}
|
||||||
>
|
>
|
||||||
{icon ? (
|
<SidebarNavItemContent
|
||||||
<span
|
active={active}
|
||||||
className={cn(
|
compact={compact}
|
||||||
'shrink-0 inline-flex items-center justify-center transition-colors duration-base',
|
icon={icon}
|
||||||
compact ? 'h-4 w-4' : 'h-5 w-5',
|
label={label}
|
||||||
active ? 'text-accent' : 'text-soft group-hover:text-accent',
|
count={count}
|
||||||
)}
|
countClassName={countClassName}
|
||||||
>
|
trailing={trailing}
|
||||||
{icon}
|
>
|
||||||
</span>
|
{children}
|
||||||
) : null}
|
</SidebarNavItemContent>
|
||||||
{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>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SidebarNavLink({
|
||||||
|
active = false,
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
countClassName,
|
||||||
|
trailing,
|
||||||
|
isDropTarget = false,
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||||
|
active?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
label?: ReactNode;
|
||||||
|
count?: number;
|
||||||
|
countClassName?: string;
|
||||||
|
trailing?: ReactNode;
|
||||||
|
isDropTarget?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className={sidebarNavItemClass({ active, compact, isDropTarget, className })}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SidebarNavItemContent
|
||||||
|
active={active}
|
||||||
|
compact={compact}
|
||||||
|
icon={icon}
|
||||||
|
label={label}
|
||||||
|
count={count}
|
||||||
|
countClassName={countClassName}
|
||||||
|
trailing={trailing}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarNavItemContent>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue