// @vitest-environment jsdom /** * The page's claims: with `chevron` the title is a button that toggles by * click and by key and says its state; an action beside it acts without * toggling; without `chevron` there is no button to find. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MenuItem } from './Menu' import { NavItem } from './NavItem' import { SectionHeader } from './SectionHeader' import { UnreadDot } from './UnreadDot' afterEach(cleanup) describe('SectionHeader', () => { it('with a chevron the title is a button that toggles, by click and by key', async () => { const onToggle = vi.fn() render() const title = screen.getByRole('button', { name: 'Section' }) expect(title.getAttribute('aria-expanded')).toBe('true') await userEvent.click(title) title.focus() await userEvent.keyboard('{Enter}') expect(onToggle).toHaveBeenCalledTimes(2) }) it('fills under the pointer when it does something, and hover="none" keeps it still', () => { const actions = [{ icon: , tooltip: 'Add', onClick: () => {} }] const lit = render() expect((lit.container.firstElementChild as HTMLElement).className).toContain('hover:bg-bg-hover') cleanup() const still = render() expect((still.container.firstElementChild as HTMLElement).className).not.toContain('hover:bg-bg-hover') }) it('an action beside the title acts, and never toggles', async () => { const onToggle = vi.fn() const add = vi.fn() render(, tooltip: 'Add', onClick: add }]} />) await userEvent.click(screen.getByRole('button', { name: 'Add' })) expect(add).toHaveBeenCalledTimes(1) expect(onToggle).not.toHaveBeenCalled() }) it('without a chevron there is no button', () => { render() expect(screen.queryByRole('button')).toBeNull() expect(screen.getByText('Section')).not.toBeNull() }) }) /** * When the buttons show. The pointer and the menu are Chrome's to prove (the * stories); what is pinned here is which element each reveal asks. */ describe('SectionHeader, the reveal', () => { const menu = const actions = [{ icon: , tooltip: 'Add', onClick: () => {} }] it('shows the buttons once the title’s toggle has keyboard focus, as a NavItem shows its ⋮ for its link', () => { const { container } = render( {}} actions={actions} menu={menu} />) const row = container.firstElementChild as HTMLElement const toggle = screen.getByRole('button', { name: 'Section' }) const buttons = screen.getByRole('button', { name: 'Add' }).parentElement! // The row is the group, and it holds the toggle: the reveal asks the row, not the buttons' own box. expect(row.className.split(' ')).toContain('group') expect(row.contains(toggle)).toBe(true) expect(buttons.className.split(' ')).toContain('group-has-[:focus-visible]:opacity-100') expect(buttons.className.split(' ')).not.toContain('has-[:focus-visible]:opacity-100') // An open menu holds them, asked of their own box: the toggle's aria-expanded must not. expect(buttons.className.split(' ')).toContain('has-[[aria-expanded=true]]:opacity-100') }) it('on a screen with no hover the buttons always show, and a dot under them steps aside', () => { const { container } = render(} actions={actions} menu={menu} />) expect(screen.getByRole('button', { name: 'Add' }).parentElement!.className.split(' ')).toContain('[@media(hover:none)]:opacity-100') expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('[@media(hover:none)]:has-[[data-unread]]:opacity-0') }) it('no reveal answers to any focus (`focus-within`): a clicked row kept its focus and lost its icon and dot (0.56.0)', () => { const { container } = render( <> } onToggle={() => {}} trailing={} actions={actions} menu={menu} /> } count={2} hint={Label} menu={menu}> , ) const classes = [...container.querySelectorAll('*')].map((e) => e.getAttribute('class') ?? '').join(' ') expect(classes).not.toContain('focus-within') // The keyboard's reveal is what replaced it. expect(classes).toContain('group-has-[:focus-visible]') }) it('with showActions="always" there is nothing to reveal', () => { render() expect(screen.getByRole('button', { name: 'Add' }).parentElement!.className).not.toContain('opacity-0') }) }) /** * The trailing slot (0.12.2, ADOPTION B23). Peek's Screener header carries its * count as a Chip there, and was the last hand-drawn folding header in the app * for want of it. */ describe('SectionHeader, the trailing slot', () => { it('holds a count beside the title, outside the button and outside the hover', () => { render( 2} actions={[{ icon: , tooltip: 'Add', onClick: () => {} }]} />, ) const count = screen.getByTestId('count') const title = screen.getByRole('button', { name: /section/i }) // Not part of the toggle: neither its name nor its hit target. expect(title.contains(count)).toBe(false) expect(title.textContent).toBe('Section') // Not part of the hover reveal either — a count is information, not an affordance. expect(count.closest('.opacity-0')).toBe(null) }) it('a dot sits in the last button’s place and steps aside for it; a count stays beside the buttons', () => { const { container } = render( } actions={[{ icon: , tooltip: 'Open', onClick: () => {} }]} menu={} />, ) const slot = container.querySelector('[data-unread]')!.parentElement! // Over the last button, out of the row's flow — not a slot of its own before them — and gone while they show. expect(slot.className.split(' ')).toEqual(expect.arrayContaining(['has-[[data-unread]]:absolute', 'has-[[data-unread]]:right-1', 'has-[[data-unread]]:w-6', 'group-hover:has-[[data-unread]]:opacity-0'])) expect(slot.matches(':has([data-unread])')).toBe(true) expect(screen.getByRole('button', { name: 'More options for Section' })).not.toBeNull() }) it('a dot is found by what it draws, not by its element: one in a wrapper of the app’s own steps aside too', () => { render( } menu={} />, ) const slot = screen.getByTestId('wrapped').parentElement! expect(slot.matches(':has([data-unread])')).toBe(true) expect(slot.className.split(' ')).toContain('has-[[data-unread]]:absolute') // A count holds no dot, so the same slot keeps it beside the buttons. cleanup() render(2} menu={} />) expect(screen.getByTestId('count').parentElement!.matches(':has([data-unread])')).toBe(false) }) it('an icon shows at rest, and folding, the arrow is in its place', () => { const { container } = render(} onToggle={() => {}} />) const icon = container.querySelector('[data-icon]')! expect(icon.parentElement!.className).toContain('group-hover:opacity-0') // The arrow is the icon's sibling, hidden until the pointer comes. expect(icon.parentElement!.parentElement!.querySelector('svg')!.getAttribute('class')).toContain('group-hover:opacity-100') }) it('a dot with no buttons sits on their axis all the same', () => { const { container } = render(} />) expect(container.querySelector('[data-unread]')!.parentElement!.className.split(' ')).toContain('has-[[data-unread]]:-mr-1') }) it('a count sits right after the title, inside the title, not at the row end', () => { render() const count = screen.getByText('2') expect(count.previousElementSibling?.textContent).toBe('Section') }) })