/**
* Unit tests for the `kc-scroll-button` scroll-target resolution logic.
*
* Strategy: `defineWebComponent` requires a real browser environment
* (Constructable Stylesheets, shadow roots) and is not suitable for jsdom.
* Instead we:
* 1. Test the `findScrollableAncestor` helper in isolation — it only uses
* DOM APIs that jsdom provides.
* 2. Test the Solid `Button` + visibility-toggle logic that drives the
* show/hide animation, using the underlying component directly (mirrors
* the pattern in `prompt-suggestions.declarative.test.tsx`).
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, cleanup, fireEvent } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { cn } from '../utils/cn';
import { Button } from '../ui/button';
import { ChevronDown } from 'lucide-solid';
afterEach(cleanup);
// ---------------------------------------------------------------------------
// findScrollableAncestor — re-implemented inline so we can test without
// importing the full element (which would call defineWebComponent).
// ---------------------------------------------------------------------------
function findScrollableAncestor(startEl: HTMLElement): HTMLElement | null {
let el: HTMLElement | null = startEl.parentElement;
while (el && el !== document.documentElement) {
const style = getComputedStyle(el);
const overflow = style.overflow + style.overflowY;
if (/auto|scroll/.test(overflow) && el.scrollHeight > el.clientHeight) {
return el;
}
el = el.parentElement;
}
return null;
}
describe('findScrollableAncestor', () => {
it('returns null when no scrollable ancestor exists', () => {
const wrapper = document.createElement('div');
const child = document.createElement('span');
wrapper.appendChild(child);
document.body.appendChild(wrapper);
expect(findScrollableAncestor(child)).toBeNull();
document.body.removeChild(wrapper);
});
it('returns the first scrollable ancestor', () => {
const scrollable = document.createElement('div');
scrollable.style.overflow = 'auto';
// jsdom doesn't do layout, so scrollHeight === clientHeight by default.
// Force a difference by mocking.
Object.defineProperty(scrollable, 'scrollHeight', { value: 500, configurable: true });
Object.defineProperty(scrollable, 'clientHeight', { value: 300, configurable: true });
const child = document.createElement('div');
scrollable.appendChild(child);
document.body.appendChild(scrollable);
expect(findScrollableAncestor(child)).toBe(scrollable);
document.body.removeChild(scrollable);
});
it('skips non-scrollable ancestors', () => {
const nonScrollable = document.createElement('div');
const scrollable = document.createElement('div');
scrollable.style.overflowY = 'scroll';
Object.defineProperty(scrollable, 'scrollHeight', { value: 500, configurable: true });
Object.defineProperty(scrollable, 'clientHeight', { value: 300, configurable: true });
const child = document.createElement('span');
nonScrollable.appendChild(child);
scrollable.appendChild(nonScrollable);
document.body.appendChild(scrollable);
expect(findScrollableAncestor(child)).toBe(scrollable);
document.body.removeChild(scrollable);
});
});
// ---------------------------------------------------------------------------
// Visibility toggle logic — ScrollButtonUI mirrors the render logic inside
// kc-scroll-button's facade.
// ---------------------------------------------------------------------------
/** Minimal SolidJS component that mirrors the button render in scroll-button.tsx. */
function ScrollButtonUI(props: {
isAtBottom: () => boolean;
onClick: () => void;
}) {
return (
);
}
describe('scroll button visibility logic', () => {
it('is visually hidden when at bottom (has opacity-0 class)', () => {
const { getByRole } = render(() => (
true} onClick={() => {}} />
));
const btn = getByRole('button', { name: /scroll to bottom/i });
expect(btn.className).toMatch(/opacity-0/);
});
it('is visible when not at bottom (has opacity-100 class)', () => {
const { getByRole } = render(() => (
false} onClick={() => {}} />
));
const btn = getByRole('button', { name: /scroll to bottom/i });
expect(btn.className).toMatch(/opacity-100/);
});
it('calls onClick when the button is clicked', () => {
const onClick = vi.fn();
const { getByRole } = render(() => (
false} onClick={onClick} />
));
fireEvent.click(getByRole('button', { name: /scroll to bottom/i }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('reacts to isAtBottom signal changes', () => {
const [atBottom, setAtBottom] = createSignal(false);
const { getByRole } = render(() => (
{}} />
));
const btn = getByRole('button', { name: /scroll to bottom/i });
expect(btn.className).toMatch(/opacity-100/);
setAtBottom(true);
expect(btn.className).toMatch(/opacity-0/);
});
});