/** * 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/); }); });