feat(doclist): improve sidebar responsiveness and toolbar interaction in document list

Enhance mobile and desktop sidebar behavior by introducing a separate state for mobile sidebar visibility and ensuring it does not persist from desktop state. Update FinderToolbar to visually indicate sidebar open state and make the toolbar sticky for better accessibility. Refine FinderSidebar width handling for improved responsiveness. Remove redundant border in mobile sidebar dialog for a cleaner appearance. These changes provide a more consistent and intuitive sidebar experience across viewports.
This commit is contained in:
Richard R 2026-05-27 20:11:43 -06:00
parent 0470f044ef
commit 53993aa3c6
4 changed files with 140 additions and 119 deletions

View file

@ -124,6 +124,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_STATE.sidebarWidth);
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>('all');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [query, setQuery] = useState('');
const [isInitialized, setIsInitialized] = useState(false);
@ -206,6 +207,12 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
isInitialized,
]);
// Mobile drawer should never auto-open from persisted desktop state.
useEffect(() => {
if (!isNarrow) return;
setMobileSidebarOpen(false);
}, [isNarrow]);
// Build the union document list.
const allDocuments: DocumentListDocument[] = useMemo(
() => [
@ -413,6 +420,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const fallbackViewMode: ViewMode =
viewMode === 'columns' && isNarrow ? 'list' : viewMode;
const effectiveSidebarOpen = isNarrow ? mobileSidebarOpen : sidebarOpen;
return (
<FinderWindow
@ -434,7 +442,12 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
setNewFolderName('');
setManualFolderPrompt(true);
}}
onToggleSidebar={() => setSidebarOpen((p) => !p)}
onToggleSidebar={() =>
isNarrow
? setMobileSidebarOpen((p) => !p)
: setSidebarOpen((p) => !p)
}
isSidebarOpen={effectiveSidebarOpen}
isNarrow={isNarrow}
leftSlot={brand}
rightSlot={appActions}
@ -460,8 +473,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
summary={summary}
/>
}
sidebarOpen={sidebarOpen}
onSidebarOpenChange={setSidebarOpen}
sidebarOpen={effectiveSidebarOpen}
onSidebarOpenChange={(open) => {
if (isNarrow) setMobileSidebarOpen(open);
else setSidebarOpen(open);
}}
>
{!isLoading && showHint && allDocuments.length > 1 && (
<div className="px-3 pt-3 shrink-0 bg-background">

View file

@ -1,6 +1,6 @@
'use client';
import { useRef, type ReactNode } from 'react';
import { useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
@ -158,8 +158,8 @@ export function FinderSidebar({
return (
<aside
style={{ width }}
className="relative h-full bg-base border-r border-offbase shrink-0 overflow-y-auto"
style={{ '--sidebar-width': `${width}px` } as CSSProperties}
className="relative h-full w-full md:[width:var(--sidebar-width)] bg-base border-r border-offbase shrink-0 overflow-y-auto"
>
<div className="p-2 flex flex-col gap-0.5">
{topSlot && (
@ -234,4 +234,3 @@ export function FinderSidebar({
</aside>
);
}

View file

@ -27,6 +27,7 @@ interface FinderToolbarProps {
onQueryChange: (q: string) => void;
onNewFolder: () => void;
onToggleSidebar: () => void;
isSidebarOpen: boolean;
/** True when the columns view should be disabled (mobile viewport). */
isNarrow: boolean;
/** App-level content rendered at the far left (brand/logo). */
@ -61,6 +62,7 @@ const TOOLBAR_BTN =
'inline-flex items-center py-1 px-2 rounded-md border bg-base text-xs transition-all duration-200 ease-out hover:scale-[1.01]';
const TOOLBAR_BTN_INACTIVE =
'border-offbase text-foreground hover:text-accent hover:border-accent hover:bg-offbase';
const TOOLBAR_BTN_ACTIVE = 'border-accent bg-offbase text-accent';
// Pill-grouped segmented control. Outer pill carries the border; inner segments are
// borderless and rely on bg/text color to show active/hover. Sized so the whole pill
@ -84,6 +86,7 @@ export function FinderToolbar({
onQueryChange,
onNewFolder,
onToggleSidebar,
isSidebarOpen,
isNarrow,
leftSlot,
rightSlot,
@ -92,138 +95,141 @@ export function FinderToolbar({
const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc;
return (
<div className="min-h-10 px-2 sm:px-3 py-1 flex items-center gap-1.5 sm:gap-2 bg-base border-b border-offbase">
{leftSlot && (
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-offbase">
{leftSlot}
</div>
)}
<div className="sticky top-0 z-40 w-full border-b border-offbase bg-base">
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">
{leftSlot && (
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-offbase">
{leftSlot}
</div>
)}
<button
type="button"
onClick={onToggleSidebar}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} shrink-0`}
aria-label="Toggle sidebar"
title="Toggle sidebar"
>
<HamburgerIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={onToggleSidebar}
className={`${TOOLBAR_BTN} ${isSidebarOpen ? TOOLBAR_BTN_ACTIVE : TOOLBAR_BTN_INACTIVE} shrink-0`}
aria-pressed={isSidebarOpen}
aria-label="Toggle sidebar"
title="Toggle sidebar"
>
<HamburgerIcon className="w-4 h-4" />
</button>
<div className={PILL}>
{VIEW_BUTTONS.map(({ value, label, Icon }) => {
const disabled = value === 'columns' && isNarrow;
const active = viewMode === value;
return (
<button
key={value}
type="button"
disabled={disabled}
onClick={() => onViewModeChange(value)}
aria-pressed={active}
aria-label={`${label} view`}
title={disabled ? `${label} (desktop only)` : `${label} view`}
className={
PILL_SEGMENT +
' h-6 w-7 ' +
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE) +
(disabled ? ' opacity-40 cursor-not-allowed hover:bg-transparent hover:text-muted' : '')
}
>
<Icon className="w-4 h-4" />
</button>
);
})}
</div>
{viewMode === 'icons' && (
<div className={`${PILL} hidden sm:inline-flex`}>
{ICON_SIZES.map(({ value, label }) => {
const active = iconSize === value;
<div className={PILL}>
{VIEW_BUTTONS.map(({ value, label, Icon }) => {
const disabled = value === 'columns' && isNarrow;
const active = viewMode === value;
return (
<button
key={value}
type="button"
onClick={() => onIconSizeChange(value)}
disabled={disabled}
onClick={() => onViewModeChange(value)}
aria-pressed={active}
aria-label={`Icon size ${label}`}
aria-label={`${label} view`}
title={disabled ? `${label} (desktop only)` : `${label} view`}
className={
PILL_SEGMENT +
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
' h-6 w-7 ' +
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE) +
(disabled ? ' opacity-40 cursor-not-allowed hover:bg-transparent hover:text-muted' : '')
}
>
{label}
<Icon className="w-4 h-4" />
</button>
);
})}
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{viewMode === 'icons' && (
<div className={`${PILL} hidden sm:inline-flex`}>
{ICON_SIZES.map(({ value, label }) => {
const active = iconSize === value;
return (
<button
key={value}
type="button"
onClick={() => onIconSizeChange(value)}
aria-pressed={active}
aria-label={`Icon size ${label}`}
className={
PILL_SEGMENT +
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
}
>
{label}
</button>
);
})}
</div>
)}
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onSortDirectionToggle}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
title="Toggle sort direction"
>
{directionLabel}
</button>
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`}
>
<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-background border border-offbase shadow-lg p-1 focus:outline-none"
>
{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-offbase text-accent' : 'text-foreground'
} ${selected ? 'font-semibold' : ''}`
}
>
{opt.label}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
<div className="flex-1 min-w-0" />
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded-md bg-background border border-offbase hover:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:border-accent transition-colors duration-200 ease-out w-[160px] md:w-[200px]">
<SearchIcon className="w-3.5 h-3.5 text-muted 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-muted"
/>
</div>
<button
type="button"
onClick={onSortDirectionToggle}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
title="Toggle sort direction"
onClick={onNewFolder}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 shrink-0`}
title="New folder"
>
{directionLabel}
<FolderPlusIcon className="w-4 h-4" />
<span className="hidden md:inline">New Folder</span>
</button>
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`}
>
<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-background border border-offbase shadow-lg p-1 focus:outline-none"
>
{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-offbase text-accent' : 'text-foreground'
} ${selected ? 'font-semibold' : ''}`
}
>
{opt.label}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
{rightSlot && (
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5">
{rightSlot}
</div>
)}
</div>
<div className="flex-1 min-w-0" />
<div className="hidden sm:flex items-center gap-1.5 px-2 py-1 rounded-md bg-background border border-offbase hover:border-accent focus-within:ring-1 focus-within:ring-accent focus-within:border-accent transition-colors duration-200 ease-out w-[160px] md:w-[200px]">
<SearchIcon className="w-3.5 h-3.5 text-muted 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-muted"
/>
</div>
<button
type="button"
onClick={onNewFolder}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 shrink-0`}
title="New folder"
>
<FolderPlusIcon className="w-4 h-4" />
<span className="hidden md:inline">New Folder</span>
</button>
{rightSlot && (
<div className="shrink-0 flex items-center gap-2 pl-1 sm:pl-2 sm:border-l sm:border-offbase ml-0.5">
{rightSlot}
</div>
)}
</div>
);
}

View file

@ -86,7 +86,7 @@ export function FinderWindow({
leaveTo="-translate-x-full"
>
<DialogPanel
className="w-[80vw] max-w-[280px] h-full bg-base border-r border-offbase shadow-xl"
className="w-[80vw] max-w-[280px] h-full bg-base shadow-xl"
onClick={() => onSidebarOpenChange(false)}
>
{sidebar}