Skip to content

Commit 578a7b7

Browse files
committed
Merge trunk into wp-admin-ui-field-visual-label.
2 parents 998eaae + 7fbcba2 commit 578a7b7

24 files changed

Lines changed: 378 additions & 97 deletions

File tree

‎packages/block-editor/src/hooks/block-hooks.scss‎

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,6 @@
1717
}
1818
}
1919

20-
.block-editor-hooks__position-control-item {
21-
22-
/**
23-
* Try to make items match the width of the anchor, subtract any margin on
24-
* items to calculate the correct inner width.
25-
*/
26-
max-inline-size: max(calc(var(--anchor-width) - (2 * var(--wp-ui-popup-padding))), 223px);
27-
}
28-
2920
.block-editor-hooks__position-control-item-hint {
3021
color: var(--wpds-color-foreground-content-neutral-weak);
3122
margin-block-start: $grid-unit-05;

‎packages/block-editor/src/hooks/position.jsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ export function PositionPanelPure( {
261261
label={ __( 'Position' ) }
262262
hideLabelFromVision
263263
description={ stickyHelpText }
264+
popupWidth="anchor"
264265
items={ options }
265266
value={ selectedOption }
266267
onValueChange={ ( selectedItem ) => {
@@ -277,7 +278,6 @@ export function PositionPanelPure( {
277278
key={ option.key }
278279
value={ option }
279280
label={ option.label }
280-
className="block-editor-hooks__position-control-item"
281281
aria-describedby={ hintId }
282282
>
283283
<div>{ option.label }</div>

‎packages/components/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
- `Popover`: Widen `offset` to also accept an object with separate main and cross axis offsets. The same applies to `BorderBoxControl`'s `popoverOffset` prop ([#82060](https://github.com/WordPress/gutenberg/pull/82060)).
88

9+
### Bug Fixes
10+
11+
- `Snackbar`: Restart the auto-dismiss timer when a notice is recreated with the same ID during removal ([#81764](https://github.com/WordPress/gutenberg/pull/81764)).
12+
913
### Internal
1014

1115
- Use the `.jsx` extension for JavaScript source files that contain JSX ([#80990](https://github.com/WordPress/gutenberg/pull/80990)).

‎packages/components/src/snackbar/index.tsx‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ForwardedRef, KeyboardEvent, MouseEvent } from 'react';
22
import clsx from 'clsx';
3+
import { useIsPresent } from 'framer-motion';
34
import { speak } from '@wordpress/a11y';
45
import {
56
useEffect,
@@ -57,6 +58,8 @@ function UnforwardedSnackbar(
5758
}: WordPressComponentProps< SnackbarProps, 'div' >,
5859
ref: ForwardedRef< any >
5960
) {
61+
const isPresent = useIsPresent();
62+
6063
function dismissMe( event: KeyboardEvent | MouseEvent ) {
6164
if ( event && event.preventDefault ) {
6265
event.preventDefault();
@@ -92,16 +95,17 @@ function UnforwardedSnackbar(
9295
} );
9396

9497
useEffect( () => {
95-
// Only set up the timeout dismiss if we're not explicitly dismissing.
98+
if ( explicitDismiss || ! isPresent ) {
99+
return;
100+
}
101+
96102
const timeoutHandle = setTimeout( () => {
97-
if ( ! explicitDismiss ) {
98-
callbacksRef.current.onDismiss?.();
99-
callbacksRef.current.onRemove?.();
100-
}
103+
callbacksRef.current.onDismiss?.();
104+
callbacksRef.current.onRemove?.();
101105
}, NOTICE_TIMEOUT );
102106

103107
return () => clearTimeout( timeoutHandle );
104-
}, [ explicitDismiss ] );
108+
}, [ explicitDismiss, isPresent ] );
105109

106110
const classes = clsx( className, 'components-snackbar', {
107111
'components-snackbar-explicit-dismiss': !! explicitDismiss,

‎packages/components/src/snackbar/test/index.tsx‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen, within } from '@testing-library/react';
1+
import { act, fireEvent, render, screen, within } from '@testing-library/react';
22
import { click } from '@ariakit/test';
33
import { speak } from '@wordpress/a11y';
44
import { SVG, Path } from '@wordpress/primitives';
@@ -14,6 +14,10 @@ describe( 'Snackbar', () => {
1414
mockedSpeak.mockReset();
1515
} );
1616

17+
afterEach( () => {
18+
jest.useRealTimers();
19+
} );
20+
1721
it( 'should render correctly', () => {
1822
render( <Snackbar>Message</Snackbar> );
1923

@@ -44,6 +48,22 @@ describe( 'Snackbar', () => {
4448
expect( icon ).toBeVisible();
4549
} );
4650

51+
it( 'should not restart auto-dismissal after an unrelated rerender', async () => {
52+
jest.useFakeTimers();
53+
const removeNotice = jest.fn();
54+
const { rerender } = render(
55+
<Snackbar onRemove={ () => removeNotice() }>Message</Snackbar>
56+
);
57+
58+
await act( async () => jest.advanceTimersByTime( 5000 ) );
59+
rerender(
60+
<Snackbar onRemove={ () => removeNotice() }>Message</Snackbar>
61+
);
62+
await act( async () => jest.advanceTimersByTime( 1000 ) );
63+
64+
expect( removeNotice ).toHaveBeenCalledTimes( 1 );
65+
} );
66+
4767
it( 'should be dismissible by clicking the snackbar', async () => {
4868
const onRemove = jest.fn();
4969
const onDismiss = jest.fn();
@@ -69,6 +89,7 @@ describe( 'Snackbar', () => {
6989
} );
7090

7191
it( 'should not be dismissible by clicking the snackbar when the `explicitDismiss` prop is set to `true`', async () => {
92+
jest.useFakeTimers();
7293
const onRemove = jest.fn();
7394
const onDismiss = jest.fn();
7495

@@ -93,7 +114,12 @@ describe( 'Snackbar', () => {
93114
'components-snackbar-explicit-dismiss'
94115
);
95116

96-
await click( snackbar );
117+
fireEvent.click( snackbar );
118+
119+
expect( onRemove ).not.toHaveBeenCalled();
120+
expect( onDismiss ).not.toHaveBeenCalled();
121+
122+
await act( async () => jest.advanceTimersByTime( 6000 ) );
97123

98124
expect( onRemove ).not.toHaveBeenCalled();
99125
expect( onDismiss ).not.toHaveBeenCalled();

‎packages/components/src/snackbar/test/list.tsx‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import { render, screen } from '@testing-library/react';
1+
import { act, render, screen } from '@testing-library/react';
22
import { click } from '@ariakit/test';
3+
import { useEffect, useState } from '@wordpress/element';
34
import SnackbarList from '../list';
45

6+
jest.mock( '@wordpress/compose', () => ( {
7+
...jest.requireActual( '@wordpress/compose' ),
8+
useReducedMotion: () => false,
9+
} ) );
10+
511
window.scrollTo = jest.fn();
612

713
describe( 'SnackbarList', () => {
814
afterEach( () => {
915
jest.resetAllMocks();
16+
jest.useRealTimers();
1017
} );
1118

1219
it( 'should get focus after a snackbar is dismissed', async () => {
@@ -36,4 +43,57 @@ describe( 'SnackbarList', () => {
3643

3744
expect( screen.getByTestId( 'snackbar-list' ) ).toHaveFocus();
3845
} );
46+
47+
it( 'should restart auto-dismissal when a notice is replaced with the same ID', async () => {
48+
jest.useFakeTimers();
49+
const onRemove = jest.fn();
50+
const notice = {
51+
id: 'ID_1',
52+
content: 'A collaborator joined.',
53+
};
54+
55+
function RecreatedNotice() {
56+
const [ notices, setNotices ] = useState( [ notice ] );
57+
const [ shouldRecreate, setShouldRecreate ] = useState( false );
58+
59+
useEffect( () => {
60+
if ( ! shouldRecreate ) {
61+
return;
62+
}
63+
64+
const timeoutHandle = setTimeout( () => {
65+
setNotices( [ { ...notice } ] );
66+
setShouldRecreate( false );
67+
}, 50 );
68+
69+
return () => clearTimeout( timeoutHandle );
70+
}, [ shouldRecreate ] );
71+
72+
return (
73+
<SnackbarList
74+
notices={ notices }
75+
onRemove={ ( id ) => {
76+
onRemove( id );
77+
setNotices( [] );
78+
setShouldRecreate( true );
79+
} }
80+
/>
81+
);
82+
}
83+
84+
render( <RecreatedNotice /> );
85+
86+
await act( async () => jest.advanceTimersByTime( 6000 ) );
87+
expect( onRemove ).toHaveBeenCalledTimes( 1 );
88+
89+
// Recreate the notice before its 100ms exit animation completes.
90+
await act( async () => jest.advanceTimersByTime( 50 ) );
91+
92+
expect( screen.getByTestId( 'snackbar' ) ).toHaveTextContent(
93+
notice.content
94+
);
95+
96+
await act( async () => jest.advanceTimersByTime( 6000 ) );
97+
expect( onRemove ).toHaveBeenCalledTimes( 2 );
98+
} );
3999
} );

‎packages/ui/CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44

55
### Breaking Changes
66

7+
- `Autocomplete.Popup`, `Combobox.Popup`, `Select.Popup`, `SearchableChipSelect`, and `SearchableChipSelectControl`: The popup now defaults to a fixed anchor width. Use `popupWidth="content"` to restore content-sized width between the anchor and available viewport bounds ([#82087](https://github.com/WordPress/gutenberg/pull/82087)).
78
- Portaled overlays (`AlertDialog`, `Autocomplete`, `Combobox`, `Dialog`, `Drawer`, `Menu`, `Popover`, and `Select`) now inherit the theme from their portal destination instead of re-emitting the trigger's nearest contextual theme. Default portals use the document root theme; custom portal containers use their DOM ancestry ([#82038](https://github.com/WordPress/gutenberg/pull/82038)).
89

910
### New Features
1011

1112
- Add `Field.VisualLabel` for a purely visual label outside `Field.Root` ([#82095](https://github.com/WordPress/gutenberg/pull/82095)).
1213

14+
### Enhancements
15+
16+
- `Autocomplete.Popup`, `Combobox.Popup`, `SearchableChipSelect`, `SearchableChipSelectControl`, `Select.Popup`, and `SelectControl`: Add `popupWidth` prop with preset width constraints (`anchor`, `content`, `sm`, `md`, `lg`, and `available`) for the item popup. `SelectControl` defaults to `content` to preserve prior popup sizing ([#82087](https://github.com/WordPress/gutenberg/pull/82087)).
17+
1318
## 0.21.0 (2026-08-26)
1419

1520
### New Features

‎packages/ui/src/form/primitives/autocomplete/popup.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,24 @@ import clsx from 'clsx';
33
import { forwardRef } from '@wordpress/element';
44
import { renderSlotWithChildren } from '../../../utils/render-slot-with-children';
55
import itemPopupStyles from '../../../utils/css/item-popup.module.css';
6+
import { getItemPopupWidthClassName } from '../../../utils/css/item-popup';
67
import { Portal } from './portal';
78
import { Positioner } from './positioner';
89
import type { AutocompletePopupProps } from './types';
910

1011
export const Popup = forwardRef< HTMLDivElement, AutocompletePopupProps >(
11-
function Popup( { className, portal, positioner, ...restProps }, ref ) {
12+
function Popup(
13+
{ className, portal, positioner, popupWidth, ...restProps },
14+
ref
15+
) {
1216
const popupContent = (
1317
<_Autocomplete.Popup
1418
ref={ ref }
15-
className={ clsx( itemPopupStyles.popup, className ) }
19+
className={ clsx(
20+
itemPopupStyles.popup,
21+
getItemPopupWidthClassName( popupWidth ),
22+
className
23+
) }
1624
{ ...restProps }
1725
/>
1826
);

‎packages/ui/src/form/primitives/autocomplete/types.ts‎

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Autocomplete as _Autocomplete } from '@base-ui/react/autocomplete';
22
import type { ReactElement } from 'react';
33
import type { ComponentProps } from '../../../utils/types';
4+
import type { ItemPopupWidthProps } from '../../../utils/css/item-popup';
45

56
export type AutocompleteCollectionProps = _Autocomplete.Collection.Props;
67

@@ -61,24 +62,25 @@ export type AutocompleteListBodyProps = ComponentProps< 'div' > & {
6162

6263
export type AutocompletePopupProps = ComponentProps<
6364
typeof _Autocomplete.Popup
64-
> & {
65-
children?: React.ReactNode;
66-
/**
67-
* Optional portal element, typically `<Autocomplete.Portal />` with custom
68-
* `container`. When omitted, `Autocomplete.Popup` uses
69-
* `Autocomplete.Portal` with default props. Do not pass `children` on the
70-
* portal element; they would be ignored.
71-
*/
72-
portal?: ReactElement< Omit< PortalProps, 'children' > >;
73-
/**
74-
* Optional positioner element, typically `<Autocomplete.Positioner />`
75-
* with custom positioning props (`side`, `align`, `sideOffset`, collision
76-
* settings, etc.). When omitted, `Autocomplete.Popup` uses
77-
* `Autocomplete.Positioner` with default props. Do not pass `children` on
78-
* the positioner element; they would be ignored.
79-
*/
80-
positioner?: ReactElement< Omit< PositionerProps, 'children' > >;
81-
};
65+
> &
66+
ItemPopupWidthProps & {
67+
children?: React.ReactNode;
68+
/**
69+
* Optional portal element, typically `<Autocomplete.Portal />` with custom
70+
* `container`. When omitted, `Autocomplete.Popup` uses
71+
* `Autocomplete.Portal` with default props. Do not pass `children` on the
72+
* portal element; they would be ignored.
73+
*/
74+
portal?: ReactElement< Omit< PortalProps, 'children' > >;
75+
/**
76+
* Optional positioner element, typically `<Autocomplete.Positioner />`
77+
* with custom positioning props (`side`, `align`, `sideOffset`, collision
78+
* settings, etc.). When omitted, `Autocomplete.Popup` uses
79+
* `Autocomplete.Positioner` with default props. Do not pass `children` on
80+
* the positioner element; they would be ignored.
81+
*/
82+
positioner?: ReactElement< Omit< PositionerProps, 'children' > >;
83+
};
8284

8385
export type AutocompleteRootProps< Value = unknown > =
8486
_Autocomplete.Root.Props< Value >;

‎packages/ui/src/form/primitives/combobox/popup.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@ import { Portal } from './portal';
55
import { Positioner } from './positioner';
66
import { renderSlotWithChildren } from '../../../utils/render-slot-with-children';
77
import itemPopupStyles from '../../../utils/css/item-popup.module.css';
8+
import { getItemPopupWidthClassName } from '../../../utils/css/item-popup';
89
import type { ComboboxPopupProps } from './types';
910

1011
export const Popup = forwardRef< HTMLDivElement, ComboboxPopupProps >(
11-
function Popup( { className, portal, positioner, ...restProps }, ref ) {
12+
function Popup(
13+
{ className, portal, positioner, popupWidth, ...restProps },
14+
ref
15+
) {
1216
const popupContent = (
1317
<_Combobox.Popup
1418
ref={ ref }
15-
className={ clsx( itemPopupStyles.popup, className ) }
19+
className={ clsx(
20+
itemPopupStyles.popup,
21+
getItemPopupWidthClassName( popupWidth ),
22+
className
23+
) }
1624
{ ...restProps }
1725
/>
1826
);

0 commit comments

Comments
 (0)