diff --git a/README.md b/README.md index 3899855..ae87486 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Web app for the Callora API marketplace: developer dashboard, API management, an - 500 error page with retry flow - 404 catch-all page - Theme playground for previewing primary/accent/surface tokens live +- Sticky bottom action bar on the theme toggle that surfaces primary theme actions after scrolling - Dev proxy to backend at `http://localhost:3000` for `/api` - **Global Command Palette**: Instantly jump to views, search APIs by name, cycle/toggle light & dark themes, or trigger vault deposits. Use `Cmd+K` on macOS or `Ctrl+K` on Windows/Linux to open. - **Pattern-based status badges**: Status indicators now use distinct textures in addition to color so they remain understandable for color-blind users and in grayscale displays. diff --git a/docs/UI-Design-System.md b/docs/UI-Design-System.md index 670420b..9fec6cd 100644 --- a/docs/UI-Design-System.md +++ b/docs/UI-Design-System.md @@ -1233,6 +1233,46 @@ switch feels intentional rather than jarring. transition (`THEME_TRANSITION_MS / 2` ms), hiding the outgoing SVG with an opacity fade so the icon swap feels deliberate (fade-out → swap → fade-in). +4. **`ThemeToggle` Sticky Action Bar** (`src/ThemeToggle.tsx`) — A fixed bottom + action bar that surfaces the page's primary theme actions once the user has + scrolled past the inline toggle button. The bar uses `IntersectionObserver` + to detect when the primary action area leaves the viewport, keeping it + hidden on short pages and avoiding expensive scroll listeners. + + **Structure:** + - `.theme-sticky-bar` — Fixed wrapper with entrance animation and safe-area + inset padding. + - `.theme-sticky-bar__inner` — Centred pill container (`max-width: 480px`). + - `.theme-sticky-bar__btn` — Individual action buttons (min 48px touch target). + - `.theme-sticky-bar__btn--primary` — Accent-gradient primary CTA. + - `.theme-sticky-bar__btn--active` — Active-state variant for the reset button. + + **Visibility Behavior:** + - Hidden by default (`opacity: 0`, `transform: translateY(100%)`). + - Appears when the inline toggle button is no longer intersecting the + viewport. + - Disappears when the toggle button scrolls back into view. + - Uses `aria-hidden` and `inert` when hidden so it is transparent to + assistive technology. + + **Accessibility:** + - `role="toolbar"` with `aria-label="Theme controls"`. + - Buttons carry `aria-label`, `aria-pressed`, and `title` attributes. + - Min 48px touch targets with WCAG-compliant `:focus-visible` outline. + - Animations respect `prefers-reduced-motion: reduce`. + + **Design Tokens:** + - Background: `var(--surface-strong)` + - Border: `var(--line)` + - Text: `var(--text)`, `var(--muted)` + - Accent: `var(--accent)` + - Shadow: `var(--shadow)` + - Radius: `var(--radius-xl)` + - Transition: `var(--transition-speed)` + + **No public API changes.** The sticky action bar is internal to + `ThemeToggle` and does not expose new props or components. + ### Opting out Any element (or its subtree) that must not participate in the theme transition — diff --git a/src/pages/ThemeToggle.test.tsx b/src/pages/ThemeToggle.test.tsx index c0da54b..3c24b18 100644 --- a/src/pages/ThemeToggle.test.tsx +++ b/src/pages/ThemeToggle.test.tsx @@ -19,6 +19,20 @@ function buildMatchMediaMock() { })); } +// ── IntersectionObserver mock ──────────────────────────────────────────────── +let observerCallback: IntersectionObserverCallback; + +const buildIntersectionObserverMock = () => { + return vi.fn().mockImplementation((callback: IntersectionObserverCallback) => { + observerCallback = callback; + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + }; + }); +}; + // ── localStorage mock ──────────────────────────────────────────────────────── const localStorageMock = (() => { let store: Record = {}; @@ -61,14 +75,29 @@ describe('ThemeToggle & Sticky Bottom Action Bar (#691 / b#014)', () => { writable: true, value: buildMatchMediaMock(), }); - Object.defineProperty(window, 'scrollY', { + Object.defineProperty(window, 'IntersectionObserver', { writable: true, configurable: true, - value: 0, + value: buildIntersectionObserverMock(), }); }); - afterEach(cleanup); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const revealStickyBar = () => { + act(() => { + observerCallback([{ isIntersecting: false } as IntersectionObserverEntry], {} as IntersectionObserver); + }); + }; + + const hideStickyBar = () => { + act(() => { + observerCallback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver); + }); + }; it('renders inline toggle button and sticky bottom action bar', () => { renderThemeToggle(); @@ -82,7 +111,7 @@ describe('ThemeToggle & Sticky Bottom Action Bar (#691 / b#014)', () => { expect(stickyBar.getAttribute('aria-label')).toBe('Theme controls'); }); - it('hides sticky bottom action bar when window.scrollY <= 120', () => { + it('hides sticky bottom action bar initially', () => { renderThemeToggle(); const stickyBar = screen.getByTestId('theme-sticky-bar'); @@ -90,28 +119,35 @@ describe('ThemeToggle & Sticky Bottom Action Bar (#691 / b#014)', () => { expect(stickyBar.getAttribute('aria-hidden')).toBe('true'); }); - it('reveals sticky bottom action bar when window.scrollY > 120', () => { + it('reveals sticky bottom action bar when primary action area scrolls out of view', () => { renderThemeToggle(); const stickyBar = screen.getByTestId('theme-sticky-bar'); expect(stickyBar.classList.contains('theme-sticky-bar--visible')).toBe(false); - act(() => { - Object.defineProperty(window, 'scrollY', { value: 150, configurable: true }); - fireEvent.scroll(window); - }); + revealStickyBar(); expect(stickyBar.classList.contains('theme-sticky-bar--visible')).toBe(true); expect(stickyBar.getAttribute('aria-hidden')).toBe('false'); }); + it('hides sticky bottom action bar when primary action area scrolls back into view', () => { + renderThemeToggle(); + + const stickyBar = screen.getByTestId('theme-sticky-bar'); + + revealStickyBar(); + expect(stickyBar.classList.contains('theme-sticky-bar--visible')).toBe(true); + + hideStickyBar(); + expect(stickyBar.classList.contains('theme-sticky-bar--visible')).toBe(false); + expect(stickyBar.getAttribute('aria-hidden')).toBe('true'); + }); + it('cycles theme mode when sticky primary cycle button is clicked', () => { renderThemeToggle(); - act(() => { - Object.defineProperty(window, 'scrollY', { value: 200, configurable: true }); - fireEvent.scroll(window); - }); + revealStickyBar(); const cycleBtn = screen.getByTestId('theme-sticky-cycle'); expect(cycleBtn).toBeTruthy(); @@ -123,10 +159,7 @@ describe('ThemeToggle & Sticky Bottom Action Bar (#691 / b#014)', () => { it('resets to system theme preference when sticky reset button is clicked', () => { renderThemeToggle(); - act(() => { - Object.defineProperty(window, 'scrollY', { value: 200, configurable: true }); - fireEvent.scroll(window); - }); + revealStickyBar(); const resetBtn = screen.getByTestId('theme-sticky-reset'); expect(resetBtn).toBeTruthy(); @@ -136,6 +169,24 @@ describe('ThemeToggle & Sticky Bottom Action Bar (#691 / b#014)', () => { expect(getPref('theme')).toBe('system'); }); + it('has proper accessibility attributes on sticky bar and buttons', () => { + renderThemeToggle(); + + revealStickyBar(); + + const stickyBar = screen.getByTestId('theme-sticky-bar'); + expect(stickyBar.getAttribute('role')).toBe('toolbar'); + expect(stickyBar.getAttribute('aria-label')).toBe('Theme controls'); + + const cycleBtn = screen.getByTestId('theme-sticky-cycle'); + expect(cycleBtn.getAttribute('aria-label')).toBeTruthy(); + expect(cycleBtn.getAttribute('aria-pressed')).toBe('true'); + + const resetBtn = screen.getByTestId('theme-sticky-reset'); + expect(resetBtn.getAttribute('aria-label')).toBeTruthy(); + expect(resetBtn.getAttribute('aria-pressed')).toBe('false'); + }); + it('renders full ThemeTogglePage with heading and overview', () => { renderThemeTogglePage(); expect(screen.getByText('Theme Controls & Sticky Action Bar')).toBeTruthy(); diff --git a/src/pages/ThemeToggle.tsx b/src/pages/ThemeToggle.tsx index e1402b7..c272126 100644 --- a/src/pages/ThemeToggle.tsx +++ b/src/pages/ThemeToggle.tsx @@ -44,18 +44,27 @@ export function ThemeToggle() { }, [theme]); // ── Scroll detection for sticky action bar ──────────────────────────────── - const SCROLL_THRESHOLD = 120; const [isScrolled, setIsScrolled] = useState(false); + const toggleRef = useRef(null); useEffect(() => { - const onScroll = () => { - setIsScrolled(window.scrollY > SCROLL_THRESHOLD); + const toggle = toggleRef.current; + if (!toggle) return; + + const handleIntersection = (entries: IntersectionObserverEntry[]) => { + const [entry] = entries; + setIsScrolled(!entry.isIntersecting); }; - onScroll(); + const observer = new IntersectionObserver(handleIntersection, { + threshold: 0, + }); + + observer.observe(toggle); - window.addEventListener('scroll', onScroll, { passive: true }); - return () => window.removeEventListener('scroll', onScroll); + return () => { + observer.disconnect(); + }; }, []); // ── Theme cycle logic ───────────────────────────────────────────────────── @@ -136,6 +145,7 @@ export function ThemeToggle() { <> {/* ── Inline header toggle ── */}