|
1 | 1 | import { expect, vi } from 'vitest'; |
2 | | -import { fireEvent, screen } from '@mui/internal-test-utils'; |
| 2 | +import * as React from 'react'; |
| 3 | +import { fireEvent, screen, waitFor } from '@mui/internal-test-utils'; |
3 | 4 | import { DirectionProvider } from '@base-ui/react/direction-provider'; |
4 | 5 | import { Accordion } from '@base-ui/react/accordion'; |
5 | 6 | import { createRenderer, describeConformance, isJSDOM } from '#test-utils'; |
| 7 | +import { REASONS } from '../../internals/reasons'; |
6 | 8 |
|
7 | 9 | const PANEL_CONTENT_1 = 'Panel contents 1'; |
8 | 10 | const PANEL_CONTENT_2 = 'Panel contents 2'; |
@@ -57,6 +59,43 @@ describe('<Accordion.Root />', () => { |
57 | 59 | expect(trigger).toHaveAttribute('aria-controls', 'custom-panel-id'); |
58 | 60 | expect(panel).toHaveAttribute('id', 'custom-panel-id'); |
59 | 61 | }); |
| 62 | + |
| 63 | + it('restores panel labeling when a manual trigger id is removed', async () => { |
| 64 | + function App() { |
| 65 | + const [triggerId, setTriggerId] = React.useState<string | undefined>('custom-trigger-id'); |
| 66 | + |
| 67 | + return ( |
| 68 | + <React.Fragment> |
| 69 | + <button type="button" onClick={() => setTriggerId(undefined)}> |
| 70 | + Remove id |
| 71 | + </button> |
| 72 | + <Accordion.Root defaultValue={[0]}> |
| 73 | + <Accordion.Item value={0}> |
| 74 | + <Accordion.Header> |
| 75 | + <Accordion.Trigger id={triggerId}>Trigger 1</Accordion.Trigger> |
| 76 | + </Accordion.Header> |
| 77 | + <Accordion.Panel>{PANEL_CONTENT_1}</Accordion.Panel> |
| 78 | + </Accordion.Item> |
| 79 | + </Accordion.Root> |
| 80 | + </React.Fragment> |
| 81 | + ); |
| 82 | + } |
| 83 | + |
| 84 | + const { user } = await render(<App />); |
| 85 | + |
| 86 | + const trigger = screen.getByRole('button', { name: 'Trigger 1' }); |
| 87 | + const panel = screen.getByText(PANEL_CONTENT_1); |
| 88 | + |
| 89 | + expect(panel).toHaveAttribute('aria-labelledby', 'custom-trigger-id'); |
| 90 | + |
| 91 | + await user.click(screen.getByRole('button', { name: 'Remove id' })); |
| 92 | + |
| 93 | + await waitFor(() => { |
| 94 | + expect(trigger).toHaveAttribute('id'); |
| 95 | + expect(trigger).not.toHaveAttribute('id', 'custom-trigger-id'); |
| 96 | + expect(panel).toHaveAttribute('aria-labelledby', trigger.id); |
| 97 | + }); |
| 98 | + }); |
60 | 99 | }); |
61 | 100 |
|
62 | 101 | describe('uncontrolled', () => { |
@@ -240,6 +279,40 @@ describe('<Accordion.Root />', () => { |
240 | 279 | expect(element).not.toHaveAttribute('data-disabled'); |
241 | 280 | }); |
242 | 281 | }); |
| 282 | + |
| 283 | + it.each(['root', 'item'] as const)( |
| 284 | + 'does not toggle or fire callbacks when the %s is disabled', |
| 285 | + async (disabledPart) => { |
| 286 | + const onValueChange = vi.fn(); |
| 287 | + const onOpenChange = vi.fn(); |
| 288 | + |
| 289 | + await render( |
| 290 | + <Accordion.Root disabled={disabledPart === 'root'} onValueChange={onValueChange}> |
| 291 | + <Accordion.Item |
| 292 | + value={0} |
| 293 | + disabled={disabledPart === 'item'} |
| 294 | + onOpenChange={onOpenChange} |
| 295 | + > |
| 296 | + <Accordion.Header> |
| 297 | + <Accordion.Trigger>Trigger 1</Accordion.Trigger> |
| 298 | + </Accordion.Header> |
| 299 | + <Accordion.Panel>{PANEL_CONTENT_1}</Accordion.Panel> |
| 300 | + </Accordion.Item> |
| 301 | + </Accordion.Root>, |
| 302 | + ); |
| 303 | + |
| 304 | + const trigger = screen.getByRole('button'); |
| 305 | + |
| 306 | + fireEvent.click(trigger); |
| 307 | + trigger.focus(); |
| 308 | + fireEvent.keyDown(trigger, { key: ' ' }); |
| 309 | + |
| 310 | + expect(trigger).toHaveAttribute('aria-expanded', 'false'); |
| 311 | + expect(screen.queryByText(PANEL_CONTENT_1)).toBe(null); |
| 312 | + expect(onValueChange.mock.calls.length).toBe(0); |
| 313 | + expect(onOpenChange.mock.calls.length).toBe(0); |
| 314 | + }, |
| 315 | + ); |
243 | 316 | }); |
244 | 317 |
|
245 | 318 | it('allows onMouseUp to call preventBaseUIHandler on the trigger', async () => { |
@@ -387,6 +460,41 @@ describe('<Accordion.Root />', () => { |
387 | 460 | expect(trigger1).toHaveFocus(); |
388 | 461 | }); |
389 | 462 |
|
| 463 | + it('Arrow keys should only put focus on accordion triggers', async () => { |
| 464 | + const { user } = await render( |
| 465 | + <Accordion.Root> |
| 466 | + <Accordion.Item> |
| 467 | + <button type="button">Nested button</button> |
| 468 | + <Accordion.Header> |
| 469 | + <Accordion.Trigger |
| 470 | + nativeButton={isNativeButton} |
| 471 | + render={isNativeButton ? undefined : <span />} |
| 472 | + > |
| 473 | + Trigger 1 |
| 474 | + </Accordion.Trigger> |
| 475 | + </Accordion.Header> |
| 476 | + <Accordion.Panel>1</Accordion.Panel> |
| 477 | + </Accordion.Item> |
| 478 | + <Accordion.Item> |
| 479 | + <Accordion.Header> |
| 480 | + <Accordion.Trigger>Trigger 2</Accordion.Trigger> |
| 481 | + </Accordion.Header> |
| 482 | + <Accordion.Panel>2</Accordion.Panel> |
| 483 | + </Accordion.Item> |
| 484 | + </Accordion.Root>, |
| 485 | + ); |
| 486 | + |
| 487 | + const trigger1 = screen.getByRole('button', { name: 'Trigger 1' }); |
| 488 | + const trigger2 = screen.getByRole('button', { name: 'Trigger 2' }); |
| 489 | + const nestedButton = screen.getByRole('button', { name: 'Nested button' }); |
| 490 | + |
| 491 | + trigger1.focus(); |
| 492 | + await user.keyboard('[ArrowDown]'); |
| 493 | + |
| 494 | + expect(trigger2).toHaveFocus(); |
| 495 | + expect(nestedButton).not.toHaveFocus(); |
| 496 | + }); |
| 497 | + |
390 | 498 | describe('key: End/Home', () => { |
391 | 499 | it('End key moves focus to the last trigger', async () => { |
392 | 500 | const { user } = await render( |
@@ -598,6 +706,63 @@ describe('<Accordion.Root />', () => { |
598 | 706 | }); |
599 | 707 | }); |
600 | 708 |
|
| 709 | + describe('BaseUIChangeEventDetails', () => { |
| 710 | + it('onOpenChange cancel() prevents opening while uncontrolled', async () => { |
| 711 | + const onValueChange = vi.fn(); |
| 712 | + |
| 713 | + await render( |
| 714 | + <Accordion.Root onValueChange={onValueChange}> |
| 715 | + <Accordion.Item |
| 716 | + value={0} |
| 717 | + onOpenChange={(nextOpen, eventDetails) => { |
| 718 | + if (nextOpen) { |
| 719 | + eventDetails.cancel(); |
| 720 | + } |
| 721 | + }} |
| 722 | + > |
| 723 | + <Accordion.Header> |
| 724 | + <Accordion.Trigger>Trigger 1</Accordion.Trigger> |
| 725 | + </Accordion.Header> |
| 726 | + <Accordion.Panel>{PANEL_CONTENT_1}</Accordion.Panel> |
| 727 | + </Accordion.Item> |
| 728 | + </Accordion.Root>, |
| 729 | + ); |
| 730 | + |
| 731 | + const trigger = screen.getByRole('button'); |
| 732 | + |
| 733 | + fireEvent.click(trigger); |
| 734 | + |
| 735 | + expect(trigger).toHaveAttribute('aria-expanded', 'false'); |
| 736 | + expect(screen.queryByText(PANEL_CONTENT_1)).toBe(null); |
| 737 | + expect(onValueChange.mock.calls.length).toBe(0); |
| 738 | + }); |
| 739 | + |
| 740 | + it('onValueChange cancel() prevents opening while uncontrolled', async () => { |
| 741 | + const onValueChange = vi.fn((_value, eventDetails) => { |
| 742 | + eventDetails.cancel(); |
| 743 | + }); |
| 744 | + |
| 745 | + await render( |
| 746 | + <Accordion.Root onValueChange={onValueChange}> |
| 747 | + <Accordion.Item value={0}> |
| 748 | + <Accordion.Header> |
| 749 | + <Accordion.Trigger>Trigger 1</Accordion.Trigger> |
| 750 | + </Accordion.Header> |
| 751 | + <Accordion.Panel>{PANEL_CONTENT_1}</Accordion.Panel> |
| 752 | + </Accordion.Item> |
| 753 | + </Accordion.Root>, |
| 754 | + ); |
| 755 | + |
| 756 | + const trigger = screen.getByRole('button'); |
| 757 | + |
| 758 | + fireEvent.click(trigger); |
| 759 | + |
| 760 | + expect(trigger).toHaveAttribute('aria-expanded', 'false'); |
| 761 | + expect(screen.queryByText(PANEL_CONTENT_1)).toBe(null); |
| 762 | + expect(onValueChange.mock.calls.length).toBe(1); |
| 763 | + }); |
| 764 | + }); |
| 765 | + |
601 | 766 | describe.skipIf(isJSDOM)('prop: multiple', () => { |
602 | 767 | it('multiple items can be open when `multiple = true`', async () => { |
603 | 768 | const { user } = await render( |
@@ -779,6 +944,7 @@ describe('<Accordion.Root />', () => { |
779 | 944 |
|
780 | 945 | expect(onValueChange.mock.calls.length).toBe(1); |
781 | 946 | expect(onValueChange.mock.lastCall?.[0]).toEqual([0]); |
| 947 | + expect(onValueChange.mock.lastCall?.[1].reason).toBe(REASONS.triggerPress); |
782 | 948 |
|
783 | 949 | await user.pointer({ keys: '[MouseLeft]', target: trigger2 }); |
784 | 950 |
|
|
0 commit comments