|
1 | 1 | import React from 'react'; |
2 | | -import { render, fireEvent } from '@testing-library/react'; |
| 2 | +import { act, fireEvent, render, screen } from '@testing-library/react'; |
3 | 3 | import QuickActions from '../index'; |
4 | 4 |
|
5 | 5 | describe('<QuickActions />', () => { |
6 | | - it('should match the snapshot', () => { |
7 | | - const { asFragment } = render( |
8 | | - <QuickActions> |
9 | | - <QuickActions.Action icon="A" tooltip="Action A" /> |
10 | | - <QuickActions.Action icon="B" tooltip="Action B" /> |
11 | | - </QuickActions> |
12 | | - ); |
13 | | - expect(asFragment()).toMatchSnapshot(); |
14 | | - }); |
| 6 | + const getPanel = () => document.querySelector('.ty-quick-actions__actions') as HTMLElement; |
15 | 7 |
|
16 | | - it('should render with default class', () => { |
17 | | - const { container } = render( |
18 | | - <QuickActions> |
19 | | - <QuickActions.Action icon="A" /> |
| 8 | + const renderActions = (props: React.ComponentProps<typeof QuickActions> = {}) => |
| 9 | + render( |
| 10 | + <QuickActions label="Composer actions" {...props}> |
| 11 | + <QuickActions.Action icon="S" label="Save draft" description="Store the latest version." /> |
| 12 | + <QuickActions.Action icon="P" label="Publish" description="Push it live now." /> |
20 | 13 | </QuickActions> |
21 | 14 | ); |
| 15 | + |
| 16 | + it('renders with the default direction and closed state', () => { |
| 17 | + const { container } = renderActions(); |
| 18 | + |
22 | 19 | expect(container.firstChild).toHaveClass('ty-quick-actions'); |
23 | 20 | expect(container.firstChild).toHaveClass('ty-quick-actions_up'); |
| 21 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'true'); |
24 | 22 | }); |
25 | 23 |
|
26 | | - it('should render actions on hover', () => { |
27 | | - const { container, getByText } = render( |
28 | | - <QuickActions> |
29 | | - <QuickActions.Action icon="A" tooltip="Action A" /> |
30 | | - </QuickActions> |
31 | | - ); |
32 | | - const root = container.firstChild as HTMLElement; |
33 | | - fireEvent.mouseEnter(root); |
34 | | - expect(root.querySelector('.ty-quick-actions__actions')).toHaveClass('ty-quick-actions__actions_open'); |
35 | | - expect(getByText('Action A')).toBeInTheDocument(); |
| 24 | + it('opens on click by default', () => { |
| 25 | + renderActions(); |
| 26 | + |
| 27 | + fireEvent.click(screen.getByRole('button', { name: 'Composer actions' })); |
| 28 | + |
| 29 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'false'); |
36 | 30 | }); |
37 | 31 |
|
38 | | - it('should close actions on mouse leave', () => { |
39 | | - const { container } = render( |
40 | | - <QuickActions> |
41 | | - <QuickActions.Action icon="A" /> |
42 | | - </QuickActions> |
43 | | - ); |
44 | | - const root = container.firstChild as HTMLElement; |
45 | | - fireEvent.mouseEnter(root); |
46 | | - expect(root.querySelector('.ty-quick-actions__actions')).toHaveClass('ty-quick-actions__actions_open'); |
47 | | - fireEvent.mouseLeave(root); |
48 | | - expect(root.querySelector('.ty-quick-actions__actions')).not.toHaveClass('ty-quick-actions__actions_open'); |
| 32 | + it('supports defaultOpen', () => { |
| 33 | + renderActions({ defaultOpen: true }); |
| 34 | + |
| 35 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'false'); |
49 | 36 | }); |
50 | 37 |
|
51 | | - it('should toggle on click with click trigger', () => { |
52 | | - const { container } = render( |
53 | | - <QuickActions trigger="click"> |
54 | | - <QuickActions.Action icon="A" /> |
| 38 | + it('supports controlled open state', () => { |
| 39 | + const { rerender } = renderActions({ open: false }); |
| 40 | + |
| 41 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'true'); |
| 42 | + |
| 43 | + rerender( |
| 44 | + <QuickActions label="Composer actions" open={true}> |
| 45 | + <QuickActions.Action icon="S" label="Save draft" description="Store the latest version." /> |
55 | 46 | </QuickActions> |
56 | 47 | ); |
57 | | - const button = container.querySelector('.ty-quick-actions__button') as HTMLElement; |
58 | | - fireEvent.click(button); |
59 | | - expect(container.querySelector('.ty-quick-actions__actions')).toHaveClass('ty-quick-actions__actions_open'); |
60 | | - fireEvent.click(button); |
61 | | - expect(container.querySelector('.ty-quick-actions__actions')).not.toHaveClass('ty-quick-actions__actions_open'); |
| 48 | + |
| 49 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'false'); |
62 | 50 | }); |
63 | 51 |
|
64 | | - it('should close on outside click with click trigger', () => { |
65 | | - const { container, getByText } = render( |
| 52 | + it('calls onOpenChange with click, outside and action sources', () => { |
| 53 | + const onOpenChange = jest.fn(); |
| 54 | + |
| 55 | + render( |
66 | 56 | <div> |
67 | | - <QuickActions trigger="click"> |
68 | | - <QuickActions.Action icon="A" /> |
| 57 | + <QuickActions label="Composer actions" onOpenChange={onOpenChange}> |
| 58 | + <QuickActions.Action icon="S" label="Save draft" /> |
69 | 59 | </QuickActions> |
70 | | - <button>Outside</button> |
| 60 | + <button type="button">Outside</button> |
71 | 61 | </div> |
72 | 62 | ); |
73 | | - const button = container.querySelector('.ty-quick-actions__button') as HTMLElement; |
74 | | - fireEvent.click(button); |
75 | | - expect(container.querySelector('.ty-quick-actions__actions')).toHaveClass('ty-quick-actions__actions_open'); |
76 | 63 |
|
77 | | - fireEvent.click(getByText('Outside')); |
| 64 | + fireEvent.click(screen.getByRole('button', { name: 'Composer actions' })); |
| 65 | + fireEvent.click(screen.getByRole('button', { name: 'Save draft' })); |
| 66 | + fireEvent.click(screen.getByRole('button', { name: 'Composer actions' })); |
| 67 | + fireEvent.click(screen.getByRole('button', { name: 'Outside' })); |
78 | 68 |
|
79 | | - expect(container.querySelector('.ty-quick-actions__actions')).not.toHaveClass('ty-quick-actions__actions_open'); |
| 69 | + expect(onOpenChange).toHaveBeenNthCalledWith(1, true, { source: 'trigger-click' }); |
| 70 | + expect(onOpenChange).toHaveBeenNthCalledWith(2, false, { source: 'action-click' }); |
| 71 | + expect(onOpenChange).toHaveBeenNthCalledWith(3, true, { source: 'trigger-click' }); |
| 72 | + expect(onOpenChange).toHaveBeenNthCalledWith(4, false, { source: 'outside' }); |
80 | 73 | }); |
81 | 74 |
|
82 | | - it('should render correct direction class', () => { |
83 | | - const directions = ['up', 'down', 'left', 'right'] as const; |
84 | | - directions.forEach((direction) => { |
85 | | - const { container } = render( |
86 | | - <QuickActions direction={direction}> |
87 | | - <QuickActions.Action icon="A" /> |
88 | | - </QuickActions> |
89 | | - ); |
90 | | - expect(container.firstChild).toHaveClass(`ty-quick-actions_${direction}`); |
91 | | - }); |
92 | | - }); |
| 75 | + it('supports hover trigger with keyboard focus', () => { |
| 76 | + jest.useFakeTimers(); |
93 | 77 |
|
94 | | - it('should not open when disabled', () => { |
95 | | - const { container } = render( |
96 | | - <QuickActions disabled> |
97 | | - <QuickActions.Action icon="A" /> |
98 | | - </QuickActions> |
99 | | - ); |
| 78 | + const { container } = renderActions({ trigger: 'hover' }); |
100 | 79 | const root = container.firstChild as HTMLElement; |
| 80 | + const trigger = screen.getByRole('button', { name: 'Composer actions' }); |
| 81 | + |
101 | 82 | fireEvent.mouseEnter(root); |
102 | | - expect(root.querySelector('.ty-quick-actions__actions')).not.toHaveClass('ty-quick-actions__actions_open'); |
103 | | - }); |
| 83 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'false'); |
104 | 84 |
|
105 | | - it('should support controlled open', () => { |
106 | | - const { container, rerender } = render( |
107 | | - <QuickActions open={false}> |
108 | | - <QuickActions.Action icon="A" /> |
109 | | - </QuickActions> |
110 | | - ); |
111 | | - expect(container.querySelector('.ty-quick-actions__actions')).not.toHaveClass('ty-quick-actions__actions_open'); |
| 85 | + fireEvent.mouseLeave(root); |
| 86 | + act(() => { |
| 87 | + jest.advanceTimersByTime(150); |
| 88 | + }); |
| 89 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'true'); |
112 | 90 |
|
113 | | - rerender( |
114 | | - <QuickActions open={true}> |
115 | | - <QuickActions.Action icon="A" /> |
116 | | - </QuickActions> |
117 | | - ); |
118 | | - expect(container.querySelector('.ty-quick-actions__actions')).toHaveClass('ty-quick-actions__actions_open'); |
119 | | - }); |
| 91 | + fireEvent.keyDown(root, { key: 'Tab' }); |
| 92 | + fireEvent.focus(trigger); |
| 93 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'false'); |
120 | 94 |
|
121 | | - it('should render custom icon', () => { |
122 | | - const { container } = render( |
123 | | - <QuickActions icon={<span data-testid="custom">X</span>}> |
124 | | - <QuickActions.Action icon="A" /> |
125 | | - </QuickActions> |
126 | | - ); |
127 | | - expect(container.querySelector('[data-testid="custom"]')).toBeInTheDocument(); |
| 95 | + jest.useRealTimers(); |
128 | 96 | }); |
129 | 97 |
|
130 | | - it('should render openIcon when open', () => { |
131 | | - const { container } = render( |
132 | | - <QuickActions open={true} openIcon={<span data-testid="open-icon">O</span>}> |
133 | | - <QuickActions.Action icon="A" /> |
134 | | - </QuickActions> |
135 | | - ); |
136 | | - expect(container.querySelector('[data-testid="open-icon"]')).toBeInTheDocument(); |
137 | | - }); |
| 98 | + it('closes on mouse leave after clicking the trigger in hover mode', () => { |
| 99 | + jest.useFakeTimers(); |
138 | 100 |
|
139 | | - it('should call onOpen and onClose callbacks', () => { |
140 | | - const onOpen = jest.fn(); |
141 | | - const onClose = jest.fn(); |
142 | | - const { container } = render( |
143 | | - <QuickActions onOpen={onOpen} onClose={onClose}> |
144 | | - <QuickActions.Action icon="A" /> |
145 | | - </QuickActions> |
146 | | - ); |
| 101 | + const { container } = renderActions({ trigger: 'hover' }); |
147 | 102 | const root = container.firstChild as HTMLElement; |
| 103 | + const trigger = screen.getByRole('button', { name: 'Composer actions' }); |
| 104 | + |
148 | 105 | fireEvent.mouseEnter(root); |
149 | | - expect(onOpen).toHaveBeenCalledTimes(1); |
| 106 | + fireEvent.mouseDown(trigger); |
| 107 | + fireEvent.click(trigger); |
150 | 108 | fireEvent.mouseLeave(root); |
151 | | - expect(onClose).toHaveBeenCalledTimes(1); |
| 109 | + |
| 110 | + act(() => { |
| 111 | + jest.advanceTimersByTime(150); |
| 112 | + }); |
| 113 | + |
| 114 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'true'); |
| 115 | + |
| 116 | + jest.useRealTimers(); |
| 117 | + }); |
| 118 | + |
| 119 | + it('closes on escape and returns focus to the trigger', () => { |
| 120 | + renderActions({ defaultOpen: true }); |
| 121 | + const trigger = screen.getByRole('button', { name: 'Composer actions' }); |
| 122 | + const action = screen.getByRole('button', { name: 'Save draft Store the latest version.' }); |
| 123 | + |
| 124 | + action.focus(); |
| 125 | + fireEvent.keyDown(action, { key: 'Escape' }); |
| 126 | + |
| 127 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'true'); |
| 128 | + expect(trigger).toHaveFocus(); |
152 | 129 | }); |
153 | 130 |
|
154 | | - it('should only call onOpen and onClose when the state changes', () => { |
155 | | - const onOpen = jest.fn(); |
156 | | - const onClose = jest.fn(); |
157 | | - const { container } = render( |
158 | | - <QuickActions onOpen={onOpen} onClose={onClose}> |
159 | | - <QuickActions.Action icon="A" /> |
| 131 | + it('keeps the panel open when keepOpen is set on an action', () => { |
| 132 | + render( |
| 133 | + <QuickActions label="Composer actions" defaultOpen> |
| 134 | + <QuickActions.Action icon="S" label="Save draft" keepOpen /> |
160 | 135 | </QuickActions> |
161 | 136 | ); |
162 | | - const root = container.firstChild as HTMLElement; |
163 | 137 |
|
164 | | - fireEvent.mouseEnter(root); |
165 | | - fireEvent.mouseEnter(root); |
166 | | - expect(onOpen).toHaveBeenCalledTimes(1); |
| 138 | + fireEvent.click(screen.getByRole('button', { name: 'Save draft' })); |
167 | 139 |
|
168 | | - fireEvent.mouseLeave(root); |
169 | | - fireEvent.mouseLeave(root); |
170 | | - expect(onClose).toHaveBeenCalledTimes(1); |
| 140 | + expect(getPanel()).toHaveAttribute('aria-hidden', 'false'); |
171 | 141 | }); |
172 | 142 |
|
173 | | - it('should disable action button', () => { |
174 | | - const onClick = jest.fn(); |
175 | | - const { container } = render( |
176 | | - <QuickActions open={true}> |
177 | | - <QuickActions.Action icon="A" disabled onClick={onClick} /> |
| 143 | + it('renders danger and loading states', () => { |
| 144 | + render( |
| 145 | + <QuickActions label="Composer actions" defaultOpen> |
| 146 | + <QuickActions.Action icon="D" label="Delete" danger /> |
| 147 | + <QuickActions.Action icon="L" label="Syncing" loading /> |
178 | 148 | </QuickActions> |
179 | 149 | ); |
180 | | - const action = container.querySelector('.ty-quick-actions__action') as HTMLButtonElement; |
181 | | - expect(action).toBeDisabled(); |
182 | | - expect(action).toHaveClass('ty-quick-actions__action_disabled'); |
| 150 | + |
| 151 | + const deleteAction = screen.getByRole('button', { name: 'Delete' }); |
| 152 | + const syncingAction = screen.getByRole('button', { name: 'Syncing' }); |
| 153 | + |
| 154 | + expect(deleteAction).toHaveClass('ty-quick-actions__action_danger'); |
| 155 | + expect(syncingAction).toBeDisabled(); |
| 156 | + expect(syncingAction).toHaveClass('ty-quick-actions__action_loading'); |
183 | 157 | }); |
184 | 158 | }); |
0 commit comments