Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions packages/block-library/src/details/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,26 @@ import {
useBlockProps,
useInnerBlocksProps,
InspectorControls,
store as blockEditorStore,
} from '@wordpress/block-editor';
import {
TextControl,
ToggleControl,
__experimentalToolsPanel as ToolsPanel,
__experimentalToolsPanelItem as ToolsPanelItem,
privateApis as componentsPrivateApis,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import { useSelect } from '@wordpress/data';

/**
* Internal dependencies
*/
import { useToolsPanelDropdownMenuProps } from '../utils/hooks';
import { unlock } from '../lock-unlock';

const { withIgnoreIMEEvents } = unlock( componentsPrivateApis );

const TEMPLATE = [
[
Expand All @@ -30,7 +36,7 @@ const TEMPLATE = [
],
];

function DetailsEdit( { attributes, setAttributes } ) {
function DetailsEdit( { attributes, setAttributes, clientId } ) {
const { name, showContent, summary, allowedBlocks, placeholder } =
attributes;
const blockProps = useBlockProps();
Expand All @@ -42,6 +48,27 @@ function DetailsEdit( { attributes, setAttributes } ) {
const [ isOpen, setIsOpen ] = useState( showContent );
const dropdownMenuProps = useToolsPanelDropdownMenuProps();

// Check if the inner blocks are selected.
const hasSelectedInnerBlock = useSelect(
( select ) =>
select( blockEditorStore ).hasSelectedInnerBlock( clientId, true ),
[ clientId ]
);

const handleSummaryKeyDown = ( event ) => {
if ( event.key === 'Enter' && ! event.shiftKey ) {
setIsOpen( ( prevIsOpen ) => ! prevIsOpen );
event.preventDefault();
}
};

// Prevent spacebar from toggling <details> while typing.
const handleSummaryKeyUp = ( event ) => {
if ( event.key === ' ' ) {
event.preventDefault();
}
};

return (
<>
<InspectorControls>
Expand Down Expand Up @@ -93,13 +120,18 @@ function DetailsEdit( { attributes, setAttributes } ) {
</InspectorControls>
<details
{ ...innerBlocksProps }
open={ isOpen }
open={ isOpen || hasSelectedInnerBlock }
onToggle={ ( event ) => setIsOpen( event.target.open ) }
>
<summary>
<summary
onKeyDown={ withIgnoreIMEEvents( handleSummaryKeyDown ) }
onKeyUp={ handleSummaryKeyUp }
>
<RichText
identifier="summary"
aria-label={ __( 'Write summary' ) }
aria-label={ __(
'Write summary. Press Enter to expand or collapse the details.'
) }
placeholder={ placeholder || __( 'Write summary…' ) }
withoutInteractiveFormatting
value={ summary }
Expand Down
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
### Internal

- Expose `normalizeTextString` method as private API ([#70178](https://github.com/WordPress/gutenberg/pull/70178)).
- Mark `withIgnoreIMEEvents()` function as private API ([#70056](https://github.com/WordPress/gutenberg/pull/70056)).

## 29.10.0 (2025-05-22)

Expand Down
2 changes: 2 additions & 0 deletions packages/components/src/private-apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ComponentsContext } from './context/context-system-provider';
import Theme from './theme';
import { Tabs } from './tabs';
import { kebabCase, normalizeTextString } from './utils/strings';
import { withIgnoreIMEEvents } from './utils/with-ignore-ime-events';
import { lock } from './lock-unlock';
import Badge from './badge';

Expand All @@ -18,6 +19,7 @@ lock( privateApis, {
Theme,
Menu,
kebabCase,
withIgnoreIMEEvents,
Copy link
Contributor

@t-hamano t-hamano May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@WordPress/gutenberg-components

We export this HOF as a private API. We need this function in the block library to prevent the Details content from being toggled by the Enter key event fired by the IME event.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: Let's test whether it works without this HOF.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: If the HOF is not used, unintended events occur in the IME and it does not work properly (See #70056 (comment)). Therefore, it is necessary to use HOF as a private API.

Badge,
normalizeTextString,
} );
143 changes: 143 additions & 0 deletions test/e2e/specs/editor/blocks/details.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* WordPress dependencies
*/
const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );

test.describe( 'Details', () => {
test.beforeEach( async ( { admin } ) => {
await admin.createNewPost();
} );

test( 'can toggle hidden blocks by pressing enter', async ( {
editor,
page,
} ) => {
// Insert a details block with empty inner blocks.
await editor.insertBlock( {
name: 'core/details',
attributes: {
summary: 'Details summary',
},
innerBlocks: [
{
name: 'core/paragraph',
attributes: {
content: 'Details content',
},
},
],
} );

// Open the details block.
await page.keyboard.press( 'Enter' );

// The inner block should be visible.
await expect(
editor.canvas.getByRole( 'document', { name: 'Block: Paragraph' } )
).toContainText( 'Details content' );

// Close the details block.
await page.keyboard.press( 'Enter' );

// The inner block should be hidden.
await expect(
editor.canvas.getByRole( 'document', { name: 'Block: Paragraph' } )
).toBeHidden();
} );

test( 'can create a multiline summary with Shift+Enter', async ( {
editor,
page,
} ) => {
// Insert a details block.
await editor.insertBlock( {
name: 'core/details',
} );

const summary = editor.canvas.getByRole( 'textbox', {
name: 'Write summary',
} );

// Add a multiline summary.
await summary.type( 'First line' );
await page.keyboard.press( 'Shift+Enter' );
await summary.type( 'Second line' );

// Verify the summary is multiline.
await expect( summary ).toHaveText( 'First line\nSecond line', {
useInnerText: true,
} );
} );

test( 'typing space in summary rich-text should not toggle details', async ( {
editor,
} ) => {
// Insert a details block.
await editor.insertBlock( {
name: 'core/details',
} );

const summary = editor.canvas.getByRole( 'textbox', {
name: 'Write summary',
} );

// Type space in the summary rich-text.
await summary.type( ' ' );

// Verify the details block is not toggled.
await expect(
editor.canvas.getByRole( 'document', { name: 'Empty block' } )
).toBeHidden();
} );

test( 'selecting hidden blocks in list view expands details and focuses content', async ( {
editor,
page,
pageUtils,
} ) => {
// Insert a details block.
await editor.insertBlock( {
name: 'core/details',
attributes: {
summary: 'Details summary',
},
innerBlocks: [
{
name: 'core/paragraph',
attributes: {
content: 'Details content',
},
},
],
} );

const listView = page.getByRole( 'treegrid', {
name: 'Block navigation structure',
} );

// Open the list view.
await pageUtils.pressKeys( 'access+o' );

// Verify inner blocks appear in the list view.
await page.keyboard.press( 'ArrowRight' );
await expect(
listView.getByRole( 'link', {
name: 'Paragraph',
} )
).toBeVisible();

// Verify the first inner block in the list view is focused.
await page.keyboard.press( 'ArrowDown' );
await expect(
listView.getByRole( 'link', {
name: 'Paragraph',
} )
).toBeFocused();

// Verify the first inner block in the editor canvas is focused.
await page.keyboard.press( 'Enter' );
await expect(
editor.canvas.getByRole( 'document', { name: 'Block: Paragraph' } )
).toBeFocused();
} );
} );
Loading