Skip to content

Commit deb38a7

Browse files
commrelayunitC-3PO
andauthored
Fix global publication keyboard shortcuts (#107)
* Fix global publication keyboard shortcuts * Gate Drive PDF uploads on setup status * Fix Escape closing active publication popups * Let Escape fall through to modal handlers * Restore shortcuts after closing toolbar popups * Prevent toolbar popups from reclaiming focus --------- Co-authored-by: C-3PO <c3po.bot@gmail.com>
1 parent 68a2c63 commit deb38a7

8 files changed

Lines changed: 263 additions & 37 deletions

File tree

src/components/publications/FilterBuilder.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ interface FilterBuilderProps {
6060
vaults: Vault[];
6161
open?: boolean;
6262
onOpenChange?: (open: boolean) => void;
63+
onCloseAutoFocus?: (event: Event) => void;
6364
}
6465

6566
const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
@@ -116,7 +117,7 @@ const needsValueInput = (operator: FilterOperator): boolean => {
116117
return !['is_empty', 'is_not_empty'].includes(operator);
117118
};
118119

119-
export function FilterBuilder({ filters, onFiltersChange, tags, vaults, open: controlledOpen, onOpenChange }: FilterBuilderProps) {
120+
export function FilterBuilder({ filters, onFiltersChange, tags, vaults, open: controlledOpen, onOpenChange, onCloseAutoFocus }: FilterBuilderProps) {
120121
const [internalOpen, setInternalOpen] = useState(false);
121122
const isOpen = controlledOpen ?? internalOpen;
122123
const setIsOpen = (v: boolean) => { setInternalOpen(v); onOpenChange?.(v); };
@@ -358,7 +359,7 @@ export function FilterBuilder({ filters, onFiltersChange, tags, vaults, open: co
358359
<SheetTrigger asChild>
359360
{filterTrigger}
360361
</SheetTrigger>
361-
<SheetContent side="bottom" className="px-4 pb-8 pt-4 max-h-[80vh] overflow-y-auto">
362+
<SheetContent side="bottom" className="px-4 pb-8 pt-4 max-h-[80vh] overflow-y-auto" onCloseAutoFocus={onCloseAutoFocus}>
362363
<SheetHeader className="mb-4">
363364
<SheetTitle className="font-mono text-left">filter_papers</SheetTitle>
364365
</SheetHeader>
@@ -378,6 +379,7 @@ export function FilterBuilder({ filters, onFiltersChange, tags, vaults, open: co
378379
side="bottom"
379380
sideOffset={8}
380381
className="w-auto min-w-[400px] p-3 bg-popover border-2"
382+
onCloseAutoFocus={onCloseAutoFocus}
381383
>
382384
{filterContent}
383385
</PopoverContent>

src/components/publications/PersistentFilterBuilder.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ interface PersistentFilterBuilderProps {
99
onFiltersChange?: (filters: PublicationFilter[]) => void;
1010
open?: boolean;
1111
onOpenChange?: (open: boolean) => void;
12+
onCloseAutoFocus?: (event: Event) => void;
1213
}
1314

14-
export function PersistentFilterBuilder({ tags, vaults, onFiltersChange, open, onOpenChange }: PersistentFilterBuilderProps) {
15+
export function PersistentFilterBuilder({ tags, vaults, onFiltersChange, open, onOpenChange, onCloseAutoFocus }: PersistentFilterBuilderProps) {
1516
const {
1617
filters: persistedFilters,
1718
updateFilters
@@ -32,6 +33,7 @@ export function PersistentFilterBuilder({ tags, vaults, onFiltersChange, open, o
3233
vaults={vaults}
3334
open={open}
3435
onOpenChange={onOpenChange}
36+
onCloseAutoFocus={onCloseAutoFocus}
3537
/>
3638
);
3739
}

src/components/publications/PublicationDialog.tsx

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { HierarchicalTagSelector } from '@/components/tags/HierarchicalTagSelect
3232
import { usePublicationRelations } from '@/hooks/usePublicationRelations';
3333
import { useAuth } from '@/hooks/useAuth';
3434
import { uploadPublicationDrivePdf, uploadVaultPublicationDrivePdf } from '@/lib/pdfUpload';
35+
import { fetchGoogleDriveStatus, GoogleDriveStatus } from '@/lib/googleDrive';
3536

3637
interface PublicationDialogProps {
3738
open: boolean;
@@ -75,7 +76,7 @@ export function PublicationDialog({
7576
closeOnSave = false,
7677
driveUrl,
7778
}: PublicationDialogProps) {
78-
const { user } = useAuth();
79+
const { user, session } = useAuth();
7980
const {
8081
relations,
8182
loading: relationsLoading,
@@ -132,6 +133,9 @@ export function PublicationDialog({
132133
const [drivePdfInput, setDrivePdfInput] = useState<string>(driveUrl ?? '');
133134
const [driveUploadStatus, setDriveUploadStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
134135
const [driveUploadError, setDriveUploadError] = useState('');
136+
const [driveStatus, setDriveStatus] = useState<GoogleDriveStatus | null>(null);
137+
const [driveStatusLoading, setDriveStatusLoading] = useState(false);
138+
const [driveStatusError, setDriveStatusError] = useState('');
135139
const drivePdfFileInputRef = useRef<HTMLInputElement | null>(null);
136140
const driveUploadResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
137141
const fullscreenCleanNotesRef = useRef<string>(''); // snapshot of notes when entering fullscreen
@@ -215,8 +219,53 @@ export function PublicationDialog({
215219
useEffect(() => { formDataRef.current = formData; }, [formData]);
216220
useEffect(() => { selectedTagsRef.current = selectedTags; }, [selectedTags]);
217221
useEffect(() => { setDrivePdfInput(driveUrl ?? ''); }, [driveUrl]);
222+
223+
useEffect(() => {
224+
if (!open || !session?.access_token) {
225+
setDriveStatus(null);
226+
setDriveStatusError('');
227+
setDriveStatusLoading(false);
228+
return;
229+
}
230+
231+
let cancelled = false;
232+
const loadDriveStatus = async () => {
233+
setDriveStatusLoading(true);
234+
setDriveStatusError('');
235+
try {
236+
const nextStatus = await fetchGoogleDriveStatus(session.access_token);
237+
if (!cancelled) setDriveStatus(nextStatus);
238+
} catch (err) {
239+
if (!cancelled) {
240+
setDriveStatus(null);
241+
setDriveStatusError((err as Error).message);
242+
}
243+
} finally {
244+
if (!cancelled) setDriveStatusLoading(false);
245+
}
246+
};
247+
248+
void loadDriveStatus();
249+
return () => {
250+
cancelled = true;
251+
};
252+
}, [open, session?.access_token]);
253+
254+
const isDriveUploadReady = Boolean(driveStatus?.linked && driveStatus.folderStatus === 'ready');
255+
const driveUploadSetupHint = !session?.access_token
256+
? 'sign in again before uploading PDFs to Google Drive.'
257+
: driveStatusLoading
258+
? 'checking Google Drive setup...'
259+
: driveStatusError
260+
? 'could not verify Google Drive setup. Open settings > storage and reconnect Google Drive.'
261+
: !driveStatus?.linked
262+
? 'connect Google Drive in profile settings > storage to upload PDFs.'
263+
: driveStatus.folderStatus !== 'ready'
264+
? 'prepare the RefHub Drive folder in profile settings > storage before uploading PDFs.'
265+
: '';
266+
218267
const handleDrivePdfFileSelected = useCallback(async (file: File | null) => {
219-
if (!file || !publication?.id) return;
268+
if (!file || !publication?.id || !isDriveUploadReady) return;
220269

221270
if (driveUploadResetRef.current) clearTimeout(driveUploadResetRef.current);
222271
setDriveUploadStatus('uploading');
@@ -235,7 +284,7 @@ export function PublicationDialog({
235284
} finally {
236285
if (drivePdfFileInputRef.current) drivePdfFileInputRef.current.value = '';
237286
}
238-
}, [currentVaultId, driveUploadContext, publication?.id]);
287+
}, [currentVaultId, driveUploadContext, isDriveUploadReady, publication?.id]);
239288

240289

241290
// ─── Last-save indicator timer (fullscreen notes) ──────────────────────────
@@ -1070,8 +1119,10 @@ export function PublicationDialog({
10701119
type="button"
10711120
variant="outline"
10721121
size="sm"
1073-
disabled={!publication?.id || driveUploadStatus === 'uploading'}
1122+
disabled={!publication?.id || !isDriveUploadReady || driveUploadStatus === 'uploading'}
1123+
aria-describedby={!isDriveUploadReady ? 'drive_pdf_setup_hint' : undefined}
10741124
onClick={() => {
1125+
if (!isDriveUploadReady) return;
10751126
setDriveUploadStatus('idle');
10761127
setDriveUploadError('');
10771128
drivePdfFileInputRef.current?.click();
@@ -1085,12 +1136,18 @@ export function PublicationDialog({
10851136
{driveUploadStatus === 'uploading' && <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />}
10861137
{driveUploadStatus === 'success' && <CheckCircle2 className="w-3.5 h-3.5 mr-1.5" />}
10871138
{driveUploadStatus === 'error' && <AlertCircle className="w-3.5 h-3.5 mr-1.5" />}
1088-
{driveUploadStatus === 'idle' && <Upload className="w-3.5 h-3.5 mr-1.5" />}
1139+
{driveUploadStatus === 'idle' && (driveStatusLoading ? <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" /> : <Upload className="w-3.5 h-3.5 mr-1.5" />)}
10891140
{driveUploadStatus === 'uploading' ? 'uploading…' :
10901141
driveUploadStatus === 'success' ? 'uploaded!' :
10911142
driveUploadStatus === 'error' ? 'retry' : 'upload_pdf'}
10921143
</Button>
10931144
</div>
1145+
{!isDriveUploadReady && driveUploadSetupHint && (
1146+
<p id="drive_pdf_setup_hint" className="text-xs font-mono text-muted-foreground flex items-start gap-1.5 pt-0.5">
1147+
<AlertCircle className="w-3 h-3 shrink-0 mt-0.5 text-fuchsia-300" />
1148+
{driveUploadSetupHint}
1149+
</p>
1150+
)}
10941151
{driveUploadStatus === 'error' && driveUploadError && (
10951152
<p className="text-xs font-mono text-destructive flex items-start gap-1.5 pt-0.5">
10961153
<AlertCircle className="w-3 h-3 shrink-0 mt-0.5" />

src/components/publications/PublicationList.tsx

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -119,18 +119,22 @@ export function PublicationList({
119119
const [propertiesOpen, setPropertiesOpen] = useState(false);
120120
const listContainerRef = useRef<HTMLDivElement>(null);
121121

122-
const { pushContext, popContext } = useKeyboardContext();
122+
const { setActiveContext } = useKeyboardContext();
123123

124-
// While any toolbar popup is open, push 'dialog' context so publication-list
125-
// shortcuts (j/k/up/down/space/enter) don't fire inside them. Radix handles
126-
// arrow key / Escape navigation natively. When all popups close, context pops
127-
// back to publication-list automatically.
128-
useEffect(() => {
129-
if (filterOpen || sortDropdownOpen || propertiesOpen) {
130-
pushContext('dialog');
131-
return () => popContext();
132-
}
133-
}, [filterOpen, sortDropdownOpen, propertiesOpen, pushContext, popContext]);
124+
const releaseToolbarFocus = useCallback(() => {
125+
requestAnimationFrame(() => {
126+
if (document.activeElement instanceof HTMLElement) {
127+
document.activeElement.blur();
128+
}
129+
listContainerRef.current?.focus();
130+
setActiveContext('publication-list');
131+
});
132+
}, [setActiveContext]);
133+
134+
const handleToolbarCloseAutoFocus = useCallback((event: Event) => {
135+
event.preventDefault();
136+
releaseToolbarFocus();
137+
}, [releaseToolbarFocus]);
134138

135139
// Calculate tag usage counts
136140
const tagUsageCounts = useMemo(() => {
@@ -271,7 +275,8 @@ export function PublicationList({
271275
setFilterOpen(false);
272276
setSortDropdownOpen(false);
273277
setPropertiesOpen(false);
274-
}, []);
278+
releaseToolbarFocus();
279+
}, [releaseToolbarFocus]);
275280

276281
const handleKbExport = useCallback(
277282
(ids: string[]) => {
@@ -290,6 +295,7 @@ export function PublicationList({
290295
onExport: handleKbExport,
291296
activateOnMount: true,
292297
bootstrapOnNav: true,
298+
appWideShortcuts: true,
293299
containerRef: listContainerRef as React.RefObject<HTMLElement>,
294300
resetKey: selectedVault?.id ?? 'all_papers',
295301
});
@@ -328,7 +334,9 @@ export function PublicationList({
328334
const selectedPublications = publications.filter((p) => selectedIds.has(p.id));
329335

330336

331-
// Meta+K / Ctrl+K → focus search (registered through keyboard system)
337+
// Meta+K / Ctrl+K → focus search (registered through keyboard system).
338+
// Escape always closes the currently active toolbar/search affordance first,
339+
// without disturbing the other shortcuts that now work app-wide.
332340
useHotkeys(
333341
'global',
334342
[
@@ -342,19 +350,56 @@ export function PublicationList({
342350
},
343351
allowInInput: true,
344352
},
353+
{
354+
combo: 'Escape',
355+
description: 'Close active search or popup',
356+
handler: () => {
357+
if (document.activeElement === searchInputRef.current) {
358+
if (searchQuery) setSearchQuery('');
359+
releaseToolbarFocus();
360+
return true;
361+
}
362+
363+
if (propertiesOpen) {
364+
setPropertiesOpen(false);
365+
releaseToolbarFocus();
366+
return true;
367+
}
368+
369+
if (sortDropdownOpen) {
370+
setSortDropdownOpen(false);
371+
releaseToolbarFocus();
372+
return true;
373+
}
374+
375+
if (filterOpen) {
376+
setFilterOpen(false);
377+
releaseToolbarFocus();
378+
return true;
379+
}
380+
381+
return false;
382+
},
383+
allowInInput: true,
384+
},
345385
],
346-
[],
386+
[filterOpen, propertiesOpen, searchQuery, sortDropdownOpen],
347387
);
348388

349-
// publication-list context: p, f, s shortcuts
389+
// Publication page shortcuts are unique app-wide; register them as appWide so
390+
// stale activeContext never forces a click before f/s/p/r work. The keyboard
391+
// provider still blocks them while typing in editable fields and while modal
392+
// contexts are active.
350393
useHotkeys(
351394
kbContext,
352395
[
353396
{
354397
combo: 'p',
355398
description: 'Show properties popup',
399+
appWide: true,
356400
handler: (e) => {
357401
e.preventDefault();
402+
releaseToolbarFocus();
358403
setPropertiesOpen((prev) => !prev);
359404
setFilterOpen(false);
360405
setSortDropdownOpen(false);
@@ -364,8 +409,10 @@ export function PublicationList({
364409
{
365410
combo: 'f',
366411
description: 'Show filter popup',
412+
appWide: true,
367413
handler: (e) => {
368414
e.preventDefault();
415+
releaseToolbarFocus();
369416
setFilterOpen((prev) => !prev);
370417
setSortDropdownOpen(false);
371418
setPropertiesOpen(false);
@@ -375,8 +422,10 @@ export function PublicationList({
375422
{
376423
combo: 's',
377424
description: 'Show sort popup',
425+
appWide: true,
378426
handler: (e) => {
379427
e.preventDefault();
428+
releaseToolbarFocus();
380429
setSortDropdownOpen((prev) => !prev);
381430
setFilterOpen(false);
382431
setPropertiesOpen(false);
@@ -386,6 +435,7 @@ export function PublicationList({
386435
{
387436
combo: 'r',
388437
description: 'Discover related papers',
438+
appWide: true,
389439
handler: (e) => {
390440
if (!onDiscoverRelated || selectedPublications.length === 0) return false;
391441
e.preventDefault();
@@ -394,7 +444,7 @@ export function PublicationList({
394444
},
395445
},
396446
],
397-
[kbContext, onDiscoverRelated, selectedPublications],
447+
[kbContext, onDiscoverRelated, releaseToolbarFocus, selectedPublications],
398448
);
399449

400450
return (
@@ -574,6 +624,7 @@ export function PublicationList({
574624
onFiltersChange={setFilters}
575625
open={filterOpen}
576626
onOpenChange={setFilterOpen}
627+
onCloseAutoFocus={handleToolbarCloseAutoFocus}
577628
/>
578629
{persistedFilters.length > 0 && (
579630
<span className="absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full z-10"></span>
@@ -602,7 +653,7 @@ export function PublicationList({
602653
)}
603654
</Button>
604655
</DropdownMenuTrigger>
605-
<DropdownMenuContent align="end" className="font-mono">
656+
<DropdownMenuContent align="end" className="font-mono" onCloseAutoFocus={handleToolbarCloseAutoFocus}>
606657
<DropdownMenuItem onClick={() => { setSortBy('created'); setSortDirection('desc'); }}>
607658
recently_added
608659
</DropdownMenuItem>
@@ -629,6 +680,7 @@ export function PublicationList({
629680
propertiesHint={<KbdHint shortcut="p" size="xs" className="hidden md:inline-flex !px-1 !py-0.5 !text-[10px] !leading-none !h-4" />}
630681
propertiesOpen={propertiesOpen}
631682
onPropertiesOpenChange={setPropertiesOpen}
683+
onPropertiesCloseAutoFocus={handleToolbarCloseAutoFocus}
632684
/>
633685

634686
{selectedIds.size > 0 && onDiscoverRelated && (

src/components/publications/ViewSettings.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface ViewSettingsProps {
3939
propertiesHint?: ReactNode;
4040
propertiesOpen?: boolean;
4141
onPropertiesOpenChange?: (open: boolean) => void;
42+
onPropertiesCloseAutoFocus?: (event: Event) => void;
4243
}
4344

4445
const COLUMN_OPTIONS: { key: keyof VisibleColumns; label: string }[] = [
@@ -83,6 +84,7 @@ export function ViewSettings({
8384
propertiesHint,
8485
propertiesOpen,
8586
onPropertiesOpenChange,
87+
onPropertiesCloseAutoFocus,
8688
}: ViewSettingsProps) {
8789
const {
8890
viewMode: persistedViewMode,
@@ -186,7 +188,7 @@ export function ViewSettings({
186188
{/* p shortcut hint now outside in PublicationList */}
187189
</Button>
188190
</PopoverTrigger>
189-
<PopoverContent align="end" className="w-56 p-3 bg-popover border-2">
191+
<PopoverContent align="end" className="w-56 p-3 bg-popover border-2" onCloseAutoFocus={onPropertiesCloseAutoFocus}>
190192
<div className="space-y-3">
191193
<h4 className="text-sm font-semibold">Visible Properties</h4>
192194
<p className="text-xs text-muted-foreground font-mono">

0 commit comments

Comments
 (0)