diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 26445de6..77fbc76f 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -111,14 +111,9 @@ describe('BreadcrumbBar — archive/unarchive', () => { }) }) -describe('BreadcrumbBar — title in breadcrumb on scroll', () => { - it('does not show title when titleHidden is false', () => { - render() - expect(screen.queryByText('Test Note')).not.toBeInTheDocument() - }) - - it('shows type and title when titleHidden is true', () => { - render() +describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => { + it('always renders title elements in the DOM', () => { + render() expect(screen.getByText('Note')).toBeInTheDocument() expect(screen.getByText('›')).toBeInTheDocument() expect(screen.getByText('Test Note')).toBeInTheDocument() @@ -126,21 +121,29 @@ describe('BreadcrumbBar — title in breadcrumb on scroll', () => { it('shows emoji icon when entry has an emoji icon', () => { const entryWithEmoji = { ...baseEntry, icon: '🚀' } - render() + render() expect(screen.getByText('🚀')).toBeInTheDocument() }) it('does not show icon when entry has a non-emoji icon', () => { const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' } - render() + render() expect(screen.queryByText('cooking-pot')).not.toBeInTheDocument() }) it('falls back to "Note" when isA is null', () => { const entryNoType = { ...baseEntry, isA: null } - render() + render() expect(screen.getByText('Note')).toBeInTheDocument() }) + + it('shadow is controlled by data-title-hidden attribute via CSS', () => { + const { container } = render() + const bar = container.querySelector('.breadcrumb-bar')! + expect(bar).not.toHaveAttribute('data-title-hidden') + bar.setAttribute('data-title-hidden', '') + expect(bar).toHaveAttribute('data-title-hidden') + }) }) describe('BreadcrumbBar — raw editor toggle', () => { diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 93b76ae4..d66c8235 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -32,8 +32,8 @@ interface BreadcrumbBarProps { onRestore?: () => void onArchive?: () => void onUnarchive?: () => void - /** When true, the note title is scrolled out of view — show it inline. */ - titleHidden?: boolean + /** Ref for direct DOM manipulation — avoids re-render on scroll. */ + barRef?: React.Ref } const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const @@ -178,22 +178,21 @@ function BreadcrumbTitle({ entry }: { entry: VaultEntry }) { } export const BreadcrumbBar = memo(function BreadcrumbBar({ - entry, titleHidden, ...actionProps + entry, barRef, ...actionProps }: BreadcrumbBarProps) { return (
-
- {titleHidden && } +
+
diff --git a/src/components/Editor.css b/src/components/Editor.css index e82998cd..2652a10c 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -23,6 +23,24 @@ opacity: 0.55; } +/* Breadcrumb bar: title + shadow toggled via data attribute (no React re-render) */ +.breadcrumb-bar { + transition: box-shadow 0.2s ease; + box-shadow: none; +} + +.breadcrumb-bar[data-title-hidden] { + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.breadcrumb-bar__title { + display: none; +} + +.breadcrumb-bar[data-title-hidden] .breadcrumb-bar__title { + display: flex; +} + /* Scroll area wrapping title + editor — single scroll context for alignment */ .editor-scroll-area { flex: 1; diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index 8fa39b8a..4373ad46 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useCallback, useRef, useState, useEffect } from 'react' +import { useCallback, useRef, useEffect } from 'react' import type { VaultEntry, NoteStatus } from '../types' import type { useCreateBlockNote } from '@blocknote/react' import { DiffView } from './DiffView' @@ -120,9 +120,9 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) { return cb ? () => cb(path) : undefined } -function ActiveTabBreadcrumb({ activeTab, titleHidden, props }: { +function ActiveTabBreadcrumb({ activeTab, barRef, props }: { activeTab: Tab - titleHidden: boolean + barRef: React.RefObject props: Omit }) { const wordCount = countWords(activeTab.content) @@ -131,7 +131,7 @@ function ActiveTabBreadcrumb({ activeTab, titleHidden, props }: { (null) - const [titleScrolledAway, setTitleScrolledAway] = useState(false) - const titleHidden = showEditor && titleScrolledAway + const breadcrumbBarRef = useRef(null) useEffect(() => { const el = titleSectionRef.current - if (!el) return + const bar = breadcrumbBarRef.current + if (!el || !bar) return const observer = new IntersectionObserver( - ([e]) => setTitleScrolledAway(!e.isIntersecting), + ([e]) => { + if (e.isIntersecting) bar.removeAttribute('data-title-hidden') + else bar.setAttribute('data-title-hidden', '') + }, { threshold: 0 }, ) observer.observe(el) - return () => observer.disconnect() + return () => { observer.disconnect(); bar.removeAttribute('data-title-hidden') } }, [activeTab?.entry.path, showEditor]) const handleSetIcon = useCallback((emoji: string) => { @@ -198,7 +201,7 @@ export function EditorContent({ {activeTab && ( )}