Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified icon.jpeg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file modified src-tauri/gen/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/gen/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file modified src-tauri/icons/128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/128x128@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/64x64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/Square107x107Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/Square142x142Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/Square150x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/Square284x284Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/Square30x30Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/Square310x310Logo.png
Binary file modified src-tauri/icons/Square44x44Logo.png
Binary file modified src-tauri/icons/Square71x71Logo.png
Binary file modified src-tauri/icons/Square89x89Logo.png
Binary file modified src-tauri/icons/StoreLogo.png
Binary file modified src-tauri/icons/icon.icns
Binary file not shown.
Binary file modified src-tauri/icons/icon.ico
Binary file not shown.
Binary file modified src-tauri/icons/icon.png
Binary file modified src-tauri/icons/ios/AppIcon-20x20@1x.png
Binary file modified src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Binary file modified src-tauri/icons/ios/AppIcon-20x20@2x.png
Binary file modified src-tauri/icons/ios/AppIcon-20x20@3x.png
Binary file modified src-tauri/icons/ios/AppIcon-29x29@1x.png
Binary file modified src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Binary file modified src-tauri/icons/ios/AppIcon-29x29@2x.png
Binary file modified src-tauri/icons/ios/AppIcon-29x29@3x.png
Binary file modified src-tauri/icons/ios/AppIcon-40x40@1x.png
Binary file modified src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Binary file modified src-tauri/icons/ios/AppIcon-40x40@2x.png
Binary file modified src-tauri/icons/ios/AppIcon-40x40@3x.png
Binary file modified src-tauri/icons/ios/AppIcon-512@2x.png
Binary file modified src-tauri/icons/ios/AppIcon-60x60@2x.png
Binary file modified src-tauri/icons/ios/AppIcon-60x60@3x.png
Binary file modified src-tauri/icons/ios/AppIcon-76x76@1x.png
Binary file modified src-tauri/icons/ios/AppIcon-76x76@2x.png
Binary file modified src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
10 changes: 9 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,8 @@ async fn tts_speak_elevenlabs(
voice_id
);
let model = model_id.unwrap_or_else(|| "eleven_multilingual_v2".to_string());
eprintln!("[elevenlabs] voice={voice_id}, model={model}, text_len={}", text.len());

let body = serde_json::json!({
"text": text,
"model_id": model,
Expand All @@ -483,11 +485,16 @@ async fn tts_speak_elevenlabs(
.json(&body)
.send()
.await
.map_err(|e| format!("ElevenLabs request failed: {e}"))?;
.map_err(|e| {
eprintln!("[elevenlabs] Request failed: {e}");
format!("ElevenLabs request failed: {e}")
})?;

let status = response.status();
eprintln!("[elevenlabs] Response status: {status}");
if !status.is_success() {
let err_body = response.text().await.unwrap_or_default();
eprintln!("[elevenlabs] Error body: {err_body}");
return Err(format!("ElevenLabs HTTP {status}: {err_body}"));
}

Expand All @@ -496,6 +503,7 @@ async fn tts_speak_elevenlabs(
.await
.map_err(|e| format!("ElevenLabs read body: {e}"))?;

eprintln!("[elevenlabs] Audio received: {} bytes", bytes.len());
Ok(STANDARD.encode(&bytes))
}

Expand Down
41 changes: 39 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,38 @@ export default function App() {

// Get items based on selection
const items = useMemo(() => {
if (showFavorites) return store.getAllItems().filter(item => item.isStarred);
if (showReadLater) return store.getAllItems().filter(item => item.isBookmarked);
if (showFavorites) {
const filtered = store.getAllItems().filter(item => item.isStarred);
const order = store.getFavoritesOrder();
if (order.length > 0) {
const posMap = new Map(order.map((id, i) => [id, i]));
return filtered.sort((a, b) => {
const posA = posMap.get(a.id);
const posB = posMap.get(b.id);
if (posA !== undefined && posB !== undefined) return posA - posB;
if (posA !== undefined) return -1;
if (posB !== undefined) return 1;
return b.publishedAt.getTime() - a.publishedAt.getTime();
});
}
return filtered;
}
if (showReadLater) {
const filtered = store.getAllItems().filter(item => item.isBookmarked);
const order = store.getReadLaterOrder();
if (order.length > 0) {
const posMap = new Map(order.map((id, i) => [id, i]));
return filtered.sort((a, b) => {
const posA = posMap.get(a.id);
const posB = posMap.get(b.id);
if (posA !== undefined && posB !== undefined) return posA - posB;
if (posA !== undefined) return -1;
if (posB !== undefined) return 1;
return b.publishedAt.getTime() - a.publishedAt.getTime();
});
}
return filtered;
}
if (selectedFeedId) return store.getItemsByFeed(selectedFeedId);
if (selectedSource) return store.getItemsBySource(selectedSource);
return store.getAllItems();
Expand Down Expand Up @@ -249,6 +279,11 @@ export default function App() {
setReaderPanelOpen(false);
}, []);

const handleReorderItems = useCallback((orderedIds: string[]) => {
if (showFavorites) store.reorderFavorites(orderedIds);
else if (showReadLater) store.reorderReadLater(orderedIds);
}, [showFavorites, showReadLater, store]);

// Keyboard shortcuts: 1/2/3 toggle panels
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -298,6 +333,7 @@ export default function App() {
onAddFeed={handleAddFeed}
onImportOpml={store.importFeeds}
onRemoveFeed={store.removeFeed}
onRenameFeed={store.renameFeed}
onSync={handleSyncAll}
isSyncing={store.isSyncing}
syncProgress={store.syncProgress}
Expand Down Expand Up @@ -333,6 +369,7 @@ export default function App() {
onToggleRead={store.toggleRead}
onToggleStar={store.toggleStar}
onToggleBookmark={store.toggleBookmark}
onReorderItems={(showFavorites || showReadLater) ? handleReorderItems : undefined}
onClose={handleCloseFeedPanel}
/>
</div>
Expand Down
20 changes: 20 additions & 0 deletions src/components/AddFeedModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { FeedSource } from '../types';
import { searchFeeds, isSearchableSource, searchLabels, type FeedSearchResult } from '../services/feedSearchService';
import { usePro } from '../contexts/ProContext';
import { PRO_LIMITS } from '../services/licenseService';
import { detectRSSHubRoute, type RSSHubMatch } from '../services/rsshubService';

interface AddFeedModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -87,6 +88,7 @@ export function AddFeedModal({ isOpen, onClose, onAdd, feedCount = 0 }: AddFeedM
const searchVersionRef = useRef(0);

const resolved = useMemo(() => resolveInput(input, source), [input, source]);
const rsshubMatch = useMemo(() => detectRSSHubRoute(input.trim()), [input]);

const triggerSearch = useCallback((query: string, currentSource: FeedSource) => {
if (debounceRef.current) clearTimeout(debounceRef.current);
Expand Down Expand Up @@ -319,6 +321,24 @@ export function AddFeedModal({ isOpen, onClose, onAdd, feedCount = 0 }: AddFeedM
<span className="resolved-url">{resolved.shorthand}</span>
</motion.div>
)}
{rsshubMatch && !resolved.shorthand && (
<motion.button
type="button"
className="rsshub-suggestion"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
onClick={() => {
setInput(rsshubMatch.rsshubUrl);
if (!name.trim()) setName(rsshubMatch.label);
}}
>
<svg className="rsshub-logo" viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<span className="rsshub-suggestion-text">Flux RSSHub disponible — {rsshubMatch.label}</span>
</motion.button>
)}
Comment on lines +324 to +341
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clear search state when applying the RSSHub suggestion.

Clicking the suggestion updates the input but leaves prior search results/search state intact, which can leave a stale dropdown open.

🛠️ Proposed fix
 onClick={() => {
   setInput(rsshubMatch.rsshubUrl);
   if (!name.trim()) setName(rsshubMatch.label);
+  setSearchResults([]);
+  setIsSearching(false);
+  setSearchQuery('');
 }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{rsshubMatch && !resolved.shorthand && (
<motion.button
type="button"
className="rsshub-suggestion"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
onClick={() => {
setInput(rsshubMatch.rsshubUrl);
if (!name.trim()) setName(rsshubMatch.label);
}}
>
<svg className="rsshub-logo" viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<span className="rsshub-suggestion-text">Flux RSSHub disponible — {rsshubMatch.label}</span>
</motion.button>
)}
{rsshubMatch && !resolved.shorthand && (
<motion.button
type="button"
className="rsshub-suggestion"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.15 }}
onClick={() => {
setInput(rsshubMatch.rsshubUrl);
if (!name.trim()) setName(rsshubMatch.label);
setSearchResults([]);
setIsSearching(false);
setSearchQuery('');
}}
>
<svg className="rsshub-logo" viewBox="0 0 24 24" width="14" height="14" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<span className="rsshub-suggestion-text">Flux RSSHub disponible — {rsshubMatch.label}</span>
</motion.button>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/AddFeedModal.tsx` around lines 324 - 341, When the RSSHub
suggestion button is clicked it sets the input and name but does not clear the
prior search state, leaving a stale dropdown; update the onClick handler inside
the rsshubMatch block (the motion.button that calls setInput and setName) to
also invoke the app’s search-reset function — e.g. call the existing
search-clearing handler (resetSearch, clearSearchResults, setSearchOpen(false),
or whatever the project uses) immediately after setInput/setName so prior
results and any open dropdown are cleared when applying the suggestion.

</div>

<div className="form-group">
Expand Down
164 changes: 164 additions & 0 deletions src/components/ExpandingPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useRef, useState } from 'react';

type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
type Phase = 'hidden' | 'mounting' | 'expanding' | 'content-in' | 'content-out' | 'closing';

interface ExpandingPanelProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
title?: string;
corner?: Corner;
}

function getCornerPercent(corner: Corner) {
const x = corner.includes('right') ? '100%' : '0%';
const y = corner.includes('bottom') ? '100%' : '0%';
return { x, y };
}

export function ExpandingPanel({
isOpen,
onClose,
children,
title,
corner = 'top-left',
}: ExpandingPanelProps) {
const panelRef = useRef<HTMLDivElement>(null);
const [phase, setPhase] = useState<Phase>('hidden');

const { x, y } = getCornerPercent(corner);

const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);

useEffect(() => {
if (isOpen) {
setPhase('mounting');
document.addEventListener('keydown', handleKeyDown);

const t1 = setTimeout(() => setPhase('expanding'), 30);
const t2 = setTimeout(() => setPhase('content-in'), 900);

return () => {
clearTimeout(t1);
clearTimeout(t2);
};
Comment on lines +39 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Event listener leak when the panel unmounts while open.

The cleanup returned from the isOpen branch only clears the timeouts. If the component is unmounted while isOpen is true (e.g., a parent conditional removes it), document.removeEventListener('keydown', handleKeyDown) is never called, leaving a dangling listener that will invoke onClose after unmount.

🔒 Proposed fix — add removeEventListener to the cleanup of the open branch
     return () => {
       clearTimeout(t1);
       clearTimeout(t2);
+      document.removeEventListener('keydown', handleKeyDown);
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (isOpen) {
setPhase('mounting');
document.addEventListener('keydown', handleKeyDown);
const t1 = setTimeout(() => setPhase('expanding'), 30);
const t2 = setTimeout(() => setPhase('content-in'), 900);
return () => {
clearTimeout(t1);
clearTimeout(t2);
};
useEffect(() => {
if (isOpen) {
setPhase('mounting');
document.addEventListener('keydown', handleKeyDown);
const t1 = setTimeout(() => setPhase('expanding'), 30);
const t2 = setTimeout(() => setPhase('content-in'), 900);
return () => {
clearTimeout(t1);
clearTimeout(t2);
document.removeEventListener('keydown', handleKeyDown);
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ExpandingPanel.tsx` around lines 39 - 50, The cleanup inside
the useEffect's isOpen branch currently clears timeouts but doesn't remove the
keydown listener, causing a leak; update the cleanup returned from that branch
in ExpandingPanel's useEffect to call document.removeEventListener('keydown',
handleKeyDown) (alongside clearTimeout(t1) and clearTimeout(t2)) so the listener
is removed when the panel unmounts or closes; reference the useEffect that sets
setPhase('mounting'), adds document.addEventListener('keydown', handleKeyDown),
and creates t1/t2 timeouts to locate where to add the removeEventListener.

} else {
if (phase === 'hidden') return;

setPhase('content-out');

const t1 = setTimeout(() => setPhase('closing'), 350);
const t2 = setTimeout(() => {
setPhase('hidden');
document.body.style.overflow = '';
}, 1350);
Comment on lines +57 to +60
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

document.body.style.overflow = '' is a no-op — the corresponding overflow = 'hidden' on open is missing.

The closing timeout resets body overflow, but nowhere in the opening path is document.body.style.overflow = 'hidden' (or 'clip') set. Either the setter was forgotten — meaning body scroll is never locked when the panel is open — or this reset line is dead code and should be removed.

♻️ Option A — lock body scroll on open, restore on close (typical modal pattern)
     if (isOpen) {
       setPhase('mounting');
       document.addEventListener('keydown', handleKeyDown);
+      document.body.style.overflow = 'hidden';

       const t1 = setTimeout(() => setPhase('expanding'), 30);
       const t2 = setTimeout(() => setPhase('content-in'), 900);

       return () => {
         clearTimeout(t1);
         clearTimeout(t2);
         document.removeEventListener('keydown', handleKeyDown);
+        document.body.style.overflow = '';
       };

Then remove the document.body.style.overflow = '' line from the closing timeout (line 59).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ExpandingPanel.tsx` around lines 57 - 60, The close timer
resets body scroll but opening never locks it; fix by saving and restoring the
body's overflow: in the ExpandingPanel component add a ref (e.g.
prevBodyOverflowRef) to store document.body.style.overflow before opening, set
document.body.style.overflow = 'hidden' when you transition the panel to the
open/visible phase (where you call setPhase('visible') or in the open timeout),
and in the closing timeout (where t2 is created and you setPhase('hidden'))
restore document.body.style.overflow = prevBodyOverflowRef.current instead of
setting it to ''. This ensures scroll is locked on open and restored exactly on
close.


document.removeEventListener('keydown', handleKeyDown);

Comment on lines +39 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Expandingpanel listener leak 🐞 Bug ⛯ Reliability

ExpandingPanel adds a global keydown listener when opening, but its open-branch cleanup doesn’t
remove it; if the component unmounts while open (or if handler identity changes), Escape listeners
can leak and fire unexpectedly.
Agent Prompt
## Issue description
A document-level keydown listener is added when the panel opens, but not removed in the open-branch cleanup, so it can leak on unmount.

## Issue Context
The app conditionally unmounts panel trees when collapsed, so unmount-while-open can occur.

## Fix Focus Areas
- src/components/ExpandingPanel.tsx[39-70]
- src/App.tsx[311-316]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return () => {
clearTimeout(t1);
clearTimeout(t2);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, handleKeyDown]);

useEffect(() => {
if (phase === 'content-in' && panelRef.current) {
const focusable = panelRef.current.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
focusable?.focus();
}
}, [phase]);

if (phase === 'hidden') return null;

const isExpanded = phase === 'expanding' || phase === 'content-in';
const isContentVisible = phase === 'content-in';
const isCollapsing = phase === 'closing';
const isContentFadingOut = phase === 'content-out';

const clipRadius = isExpanded || isContentFadingOut ? '200%' : '0%';
const clipPath = `circle(${clipRadius} at ${x} ${y})`;

const clipTransition = isCollapsing
? 'clip-path 800ms cubic-bezier(0.55, 0, 1, 0.45)'
: 'clip-path 1200ms cubic-bezier(0.22, 1, 0.36, 1)';

return (
<>
{/* Overlay */}
<div
className="expanding-panel-overlay"
style={{
opacity: isExpanded || isContentFadingOut ? 1 : 0,
transition: isExpanded || isContentFadingOut
? 'opacity 700ms ease'
: 'opacity 500ms ease',
}}
onClick={onClose}
/>

{/* Panel */}
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={title || 'Panneau'}
className="expanding-panel"
style={{ clipPath, transition: clipTransition }}
Comment on lines +110 to +116
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

aria-modal="true" dialog lacks a focus trap.

Declaring aria-modal="true" signals to assistive technologies that interaction is restricted to this dialog, but there is no Tab-key interception or sentinel elements to keep focus inside the panel. Keyboard-only and screen-reader users can Tab out of the dialog while it is open.

Add focus sentinels (hidden focusable elements at the start and end of the panel) that redirect focus back in, or intercept Tab/Shift+Tab in the handleKeyDown callback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/ExpandingPanel.tsx` around lines 110 - 116, The ExpandingPanel
component declares role="dialog" with aria-modal="true" but lacks a focus trap;
update the component to trap focus while open by either adding two hidden
focusable sentinel elements (start/end sentinels inside the element referenced
by panelRef) that on focus move focus to the first/last focusable element, or by
enhancing the existing handleKeyDown to intercept Tab and Shift+Tab and cycle
focus within the panel's focusable elements; ensure the logic runs only when the
panel is open, uses panelRef to query tabbable elements, and restores focus when
the panel closes.

>
{/* Header */}
<header
className="expanding-panel-header"
style={{
transform: isContentVisible
? 'translateY(0)'
: isContentFadingOut
? 'translateY(0)'
: 'translateY(-16px)',
opacity: isContentVisible ? 1 : isContentFadingOut ? 0 : 0,
transition: isContentVisible
? 'transform 700ms ease-out, opacity 700ms ease-out'
: 'transform 300ms ease-in, opacity 300ms ease-in',
}}
>
{title && <h2 className="expanding-panel-title">{title}</h2>}
<button
className="expanding-panel-close"
onClick={onClose}
aria-label="Fermer le panneau"
>
</button>
</header>

{/* Content */}
<div className="expanding-panel-body">
<div
style={{
transform: isContentVisible
? 'translateY(0)'
: isContentFadingOut
? 'translateY(0)'
: 'translateY(32px)',
opacity: isContentVisible ? 1 : 0,
transition: isContentVisible
? 'transform 1000ms ease-out, opacity 1000ms ease-out'
: 'transform 300ms ease-in, opacity 300ms ease-in',
}}
>
{children}
</div>
</div>
</div>
</>
);
}
Loading