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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions docs/UI-Design-System.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
85 changes: 68 additions & 17 deletions src/pages/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
Expand Down Expand Up @@ -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();
Expand All @@ -82,36 +111,43 @@ 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');
expect(stickyBar.classList.contains('theme-sticky-bar--visible')).toBe(false);
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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down
22 changes: 16 additions & 6 deletions src/pages/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLButtonElement>(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 ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -136,6 +145,7 @@ export function ThemeToggle() {
<>
{/* ── Inline header toggle ── */}
<button
ref={toggleRef}
type="button"
className="theme-toggle"
onClick={cycleTheme}
Expand Down
15 changes: 15 additions & 0 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "@testing-library/jest-dom";
import { vi } from "vitest";

// Mock matchMedia for components that use it (e.g., Tabs)
// Use Object.defineProperty to properly set it on window
Expand Down Expand Up @@ -52,3 +53,17 @@ Object.defineProperty(globalThis, "localStorage", {
writable: true,
});

// Mock IntersectionObserver for components that use it (e.g., ThemeToggle sticky bar)
const mockIntersectionObserver = vi.fn().mockImplementation((callback: IntersectionObserverCallback) => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
trigger: (entries: IntersectionObserverEntry[]) => callback(entries, {} as IntersectionObserver),
}));

Object.defineProperty(window, "IntersectionObserver", {
value: mockIntersectionObserver,
writable: true,
configurable: true,
});

Loading
Loading