Skip to content

Commit bccf1c0

Browse files
committed
feat(react): redesign quick actions
1 parent bbf3205 commit bccf1c0

14 files changed

Lines changed: 765 additions & 459 deletions

packages/react/src/quick-actions/__tests__/__snapshots__/quick-actions.test.tsx.snap

Lines changed: 0 additions & 55 deletions
This file was deleted.
Lines changed: 107 additions & 133 deletions
Original file line numberDiff line numberDiff line change
@@ -1,184 +1,158 @@
11
import React from 'react';
2-
import { render, fireEvent } from '@testing-library/react';
2+
import { act, fireEvent, render, screen } from '@testing-library/react';
33
import QuickActions from '../index';
44

55
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;
157

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." />
2013
</QuickActions>
2114
);
15+
16+
it('renders with the default direction and closed state', () => {
17+
const { container } = renderActions();
18+
2219
expect(container.firstChild).toHaveClass('ty-quick-actions');
2320
expect(container.firstChild).toHaveClass('ty-quick-actions_up');
21+
expect(getPanel()).toHaveAttribute('aria-hidden', 'true');
2422
});
2523

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');
3630
});
3731

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');
4936
});
5037

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." />
5546
</QuickActions>
5647
);
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');
6250
});
6351

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(
6656
<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" />
6959
</QuickActions>
70-
<button>Outside</button>
60+
<button type="button">Outside</button>
7161
</div>
7262
);
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');
7663

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' }));
7868

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' });
8073
});
8174

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();
9377

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' });
10079
const root = container.firstChild as HTMLElement;
80+
const trigger = screen.getByRole('button', { name: 'Composer actions' });
81+
10182
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');
10484

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');
11290

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');
12094

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();
12896
});
12997

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();
138100

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' });
147102
const root = container.firstChild as HTMLElement;
103+
const trigger = screen.getByRole('button', { name: 'Composer actions' });
104+
148105
fireEvent.mouseEnter(root);
149-
expect(onOpen).toHaveBeenCalledTimes(1);
106+
fireEvent.mouseDown(trigger);
107+
fireEvent.click(trigger);
150108
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();
152129
});
153130

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 />
160135
</QuickActions>
161136
);
162-
const root = container.firstChild as HTMLElement;
163137

164-
fireEvent.mouseEnter(root);
165-
fireEvent.mouseEnter(root);
166-
expect(onOpen).toHaveBeenCalledTimes(1);
138+
fireEvent.click(screen.getByRole('button', { name: 'Save draft' }));
167139

168-
fireEvent.mouseLeave(root);
169-
fireEvent.mouseLeave(root);
170-
expect(onClose).toHaveBeenCalledTimes(1);
140+
expect(getPanel()).toHaveAttribute('aria-hidden', 'false');
171141
});
172142

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 />
178148
</QuickActions>
179149
);
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');
183157
});
184158
});

0 commit comments

Comments
 (0)