Skip to content

Commit e9c4995

Browse files
committed
Menus: Add non-clickable placeholder menu item type
1 parent bd4e3c9 commit e9c4995

5 files changed

Lines changed: 153 additions & 60 deletions

File tree

src/js/_enqueues/lib/nav-menu.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,18 @@
11161116
}
11171117
});
11181118

1119+
$( '#custom-menu-item-placeholder' ).on( 'change', function() {
1120+
if ( $( this ).is( ':checked' ) ) {
1121+
$( '#menu-item-url-wrap' ).hide().removeClass( 'has-error' );
1122+
$( '#custom-menu-item-type' ).val( 'placeholder' );
1123+
$( '#custom-menu-item-url' ).removeClass( 'form-invalid' ).removeAttr( 'aria-invalid' ).removeAttr( 'aria-describedby' );
1124+
$( '#custom-url-error' ).hide();
1125+
} else {
1126+
$( '#menu-item-url-wrap' ).show();
1127+
$( '#custom-menu-item-type' ).val( 'custom' );
1128+
}
1129+
});
1130+
11191131
$( '#submit-customlinkdiv' ).on( 'click', function (e) {
11201132
var urlInput = $( '#custom-menu-item-url' ),
11211133
url = urlInput.val().trim(),
@@ -1127,6 +1139,11 @@
11271139
errorMessage.hide();
11281140
urlWrap.removeClass( 'has-error' );
11291141

1142+
// Placeholder items intentionally have no URL; skip validation.
1143+
if ( $( '#custom-menu-item-placeholder' ).is( ':checked' ) ) {
1144+
return;
1145+
}
1146+
11301147
/*
11311148
* Allow URLs including:
11321149
* - http://example.com/
@@ -1458,6 +1475,22 @@
14581475

14591476
processMethod = processMethod || api.addMenuItemToBottom;
14601477

1478+
// Placeholder items skip URL validation and use an empty URL.
1479+
if ( $( '#custom-menu-item-placeholder' ).is( ':checked' ) ) {
1480+
$( '.customlinkdiv .spinner' ).addClass( 'is-active' );
1481+
api.addItemToMenu(
1482+
{'-1': {'menu-item-type': 'placeholder', 'menu-item-url': '', 'menu-item-title': label, 'menu-item-db-id': 0, 'menu-item-object': 'custom', 'menu-item-parent-id': 0}},
1483+
processMethod,
1484+
function() {
1485+
$( '.customlinkdiv .spinner' ).removeClass( 'is-active' );
1486+
$('#custom-menu-item-name').val('').trigger( 'blur' );
1487+
$( '#custom-menu-item-url' ).val( '' ).attr( 'placeholder', 'https://' );
1488+
$( '#custom-menu-item-placeholder' ).prop( 'checked', false ).trigger( 'change' );
1489+
}
1490+
);
1491+
return;
1492+
}
1493+
14611494
/*
14621495
* Allow URLs including:
14631496
* - http://example.com/

src/wp-admin/includes/nav-menu.php

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,13 @@ function wp_nav_menu_item_link_meta_box() {
361361

362362
?>
363363
<div class="customlinkdiv" id="customlinkdiv">
364-
<input type="hidden" value="custom" name="menu-item[<?php echo $_nav_menu_placeholder; ?>][menu-item-type]" />
364+
<input type="hidden" id="custom-menu-item-type" value="custom" name="menu-item[<?php echo $_nav_menu_placeholder; ?>][menu-item-type]" />
365+
<p id="menu-item-placeholder-wrap" class="wp-clearfix">
366+
<label>
367+
<input type="checkbox" id="custom-menu-item-placeholder"<?php wp_nav_menu_disabled_check( $nav_menu_selected_id ); ?> />
368+
<?php _e( 'No URL (use as a section label)' ); ?>
369+
</label>
370+
</p>
365371
<p id="menu-item-url-wrap" class="wp-clearfix">
366372
<label for="custom-menu-item-url"><?php _e( 'URL' ); ?></label>
367373
<input id="custom-menu-item-url" name="menu-item[<?php echo $_nav_menu_placeholder; ?>][menu-item-url]"
@@ -1159,11 +1165,11 @@ function wp_save_nav_menu_items( $menu_id = 0, $menu_data = array() ) {
11591165
(
11601166
// And item type either isn't set.
11611167
! isset( $_item_object_data['menu-item-type'] ) ||
1162-
// Or URL is the default.
1163-
in_array( $_item_object_data['menu-item-url'], array( 'https://', 'http://', '' ), true ) ||
1164-
// Or it's not a custom menu item (but not the custom home page).
1165-
! ( 'custom' === $_item_object_data['menu-item-type'] && ! isset( $_item_object_data['menu-item-db-id'] ) ) ||
1166-
// Or it *is* a custom menu item that already exists.
1168+
// Or URL is the default (placeholders are exempt — they intentionally have no URL).
1169+
( in_array( $_item_object_data['menu-item-url'], array( 'https://', 'http://', '' ), true ) && 'placeholder' !== $_item_object_data['menu-item-type'] ) ||
1170+
// Or it's not a custom or placeholder menu item (but not the custom home page).
1171+
! ( in_array( $_item_object_data['menu-item-type'], array( 'custom', 'placeholder' ), true ) && ! isset( $_item_object_data['menu-item-db-id'] ) ) ||
1172+
// Or it *is* a custom/placeholder menu item that already exists.
11671173
! empty( $_item_object_data['menu-item-db-id'] )
11681174
)
11691175
) {

src/wp-includes/class-walker-nav-menu.php

Lines changed: 87 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -248,61 +248,98 @@ public function start_el( &$output, $data_object, $depth = 0, $args = null, $cur
248248
*/
249249
$title = apply_filters( 'nav_menu_item_title', $title, $menu_item, $args, $depth );
250250

251-
$atts = array();
252-
$atts['target'] = ! empty( $menu_item->target ) ? $menu_item->target : '';
253-
$atts['rel'] = ! empty( $menu_item->xfn ) ? $menu_item->xfn : '';
251+
if ( 'placeholder' === $menu_item->type ) {
252+
/*
253+
* Placeholder items render as a non-interactive `<span>` rather than an `<a>`.
254+
* Because most themes style nav links via selectors like `> a`, the `<span>`
255+
* will not automatically inherit those styles. Themes that wish to display
256+
* placeholders consistently with their other nav items should target the
257+
* `menu-item-type-placeholder` class that is added to the parent `<li>`:
258+
*
259+
* .menu-item-type-placeholder > span {
260+
* display: block;
261+
* cursor: default;
262+
* }
263+
*
264+
* Use the `nav_menu_placeholder_attributes` filter to add custom attributes
265+
* or classes directly to the `<span>` element.
266+
*/
267+
268+
/**
269+
* Filters the HTML attributes applied to a placeholder menu item's span element.
270+
*
271+
* @since x.x.x
272+
*
273+
* @param array $atts The HTML attributes applied to the span element, empty strings are ignored.
274+
* @param WP_Post $menu_item The current menu item object.
275+
* @param stdClass $args An object of wp_nav_menu() arguments.
276+
* @param int $depth Depth of menu item. Used for padding.
277+
*/
278+
$span_atts = apply_filters( 'nav_menu_placeholder_attributes', array(), $menu_item, $args, $depth );
279+
$attributes = $this->build_atts( $span_atts );
280+
281+
$item_output = $args->before;
282+
$item_output .= '<span' . $attributes . '>';
283+
$item_output .= $args->link_before . $title . $args->link_after;
284+
$item_output .= '</span>';
285+
$item_output .= $args->after;
286+
} else {
287+
$atts = array();
288+
$atts['target'] = ! empty( $menu_item->target ) ? $menu_item->target : '';
289+
$atts['rel'] = ! empty( $menu_item->xfn ) ? $menu_item->xfn : '';
290+
291+
if ( ! empty( $menu_item->url ) ) {
292+
if ( $this->privacy_policy_url === $menu_item->url ) {
293+
$atts['rel'] = empty( $atts['rel'] ) ? 'privacy-policy' : $atts['rel'] . ' privacy-policy';
294+
}
295+
296+
$atts['href'] = $menu_item->url;
297+
} else {
298+
$atts['href'] = '';
299+
}
254300

255-
if ( ! empty( $menu_item->url ) ) {
256-
if ( $this->privacy_policy_url === $menu_item->url ) {
257-
$atts['rel'] = empty( $atts['rel'] ) ? 'privacy-policy' : $atts['rel'] . ' privacy-policy';
301+
$atts['aria-current'] = $menu_item->current ? 'page' : '';
302+
303+
// Add title attribute only if it does not match the link text (before or after filtering).
304+
if ( ! empty( $menu_item->attr_title )
305+
&& trim( strtolower( $menu_item->attr_title ) ) !== trim( strtolower( $menu_item->title ) )
306+
&& trim( strtolower( $menu_item->attr_title ) ) !== trim( strtolower( $the_title_filtered ) )
307+
&& trim( strtolower( $menu_item->attr_title ) ) !== trim( strtolower( $title ) )
308+
) {
309+
$atts['title'] = $menu_item->attr_title;
310+
} else {
311+
$atts['title'] = '';
258312
}
259313

260-
$atts['href'] = $menu_item->url;
261-
} else {
262-
$atts['href'] = '';
314+
/**
315+
* Filters the HTML attributes applied to a menu item's anchor element.
316+
*
317+
* @since 3.6.0
318+
* @since 4.1.0 The `$depth` parameter was added.
319+
*
320+
* @param array $atts {
321+
* The HTML attributes applied to the menu item's `<a>` element, empty strings are ignored.
322+
*
323+
* @type string $title Title attribute.
324+
* @type string $target Target attribute.
325+
* @type string $rel The rel attribute.
326+
* @type string $href The href attribute.
327+
* @type string $aria-current The aria-current attribute.
328+
* }
329+
* @param WP_Post $menu_item The current menu item object.
330+
* @param stdClass $args An object of wp_nav_menu() arguments.
331+
* @param int $depth Depth of menu item. Used for padding.
332+
*/
333+
$atts = apply_filters( 'nav_menu_link_attributes', $atts, $menu_item, $args, $depth );
334+
$attributes = $this->build_atts( $atts );
335+
336+
$item_output = $args->before;
337+
$item_output .= '<a' . $attributes . '>';
338+
$item_output .= $args->link_before . $title . $args->link_after;
339+
$item_output .= '</a>';
340+
$item_output .= $args->after;
263341
}
264342

265-
$atts['aria-current'] = $menu_item->current ? 'page' : '';
266-
267-
// Add title attribute only if it does not match the link text (before or after filtering).
268-
if ( ! empty( $menu_item->attr_title )
269-
&& trim( strtolower( $menu_item->attr_title ) ) !== trim( strtolower( $menu_item->title ) )
270-
&& trim( strtolower( $menu_item->attr_title ) ) !== trim( strtolower( $the_title_filtered ) )
271-
&& trim( strtolower( $menu_item->attr_title ) ) !== trim( strtolower( $title ) )
272-
) {
273-
$atts['title'] = $menu_item->attr_title;
274-
} else {
275-
$atts['title'] = '';
276-
}
277-
278-
/**
279-
* Filters the HTML attributes applied to a menu item's anchor element.
280-
*
281-
* @since 3.6.0
282-
* @since 4.1.0 The `$depth` parameter was added.
283-
*
284-
* @param array $atts {
285-
* The HTML attributes applied to the menu item's `<a>` element, empty strings are ignored.
286-
*
287-
* @type string $title Title attribute.
288-
* @type string $target Target attribute.
289-
* @type string $rel The rel attribute.
290-
* @type string $href The href attribute.
291-
* @type string $aria-current The aria-current attribute.
292-
* }
293-
* @param WP_Post $menu_item The current menu item object.
294-
* @param stdClass $args An object of wp_nav_menu() arguments.
295-
* @param int $depth Depth of menu item. Used for padding.
296-
*/
297-
$atts = apply_filters( 'nav_menu_link_attributes', $atts, $menu_item, $args, $depth );
298-
$attributes = $this->build_atts( $atts );
299-
300-
$item_output = $args->before;
301-
$item_output .= '<a' . $attributes . '>';
302-
$item_output .= $args->link_before . $title . $args->link_after;
303-
$item_output .= '</a>';
304-
$item_output .= $args->after;
305-
306343
/**
307344
* Filters a menu item's starting output.
308345
*

src/wp-includes/nav-menu.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,10 @@ function wp_update_nav_menu_item( $menu_id = 0, $menu_item_db_id = 0, $menu_item
475475

476476
$original_parent = 0 < $menu_item_db_id ? get_post_field( 'post_parent', $menu_item_db_id ) : 0;
477477

478-
if ( 'custom' === $args['menu-item-type'] ) {
478+
if ( 'placeholder' === $args['menu-item-type'] ) {
479+
// Placeholder items intentionally have no URL.
480+
$args['menu-item-url'] = '';
481+
} elseif ( 'custom' === $args['menu-item-type'] ) {
479482
// If custom menu item, trim the URL.
480483
$args['menu-item-url'] = trim( $args['menu-item-url'] );
481484
} else {
@@ -573,9 +576,9 @@ function wp_update_nav_menu_item( $menu_id = 0, $menu_item_db_id = 0, $menu_item
573576
}
574577
}
575578

576-
if ( 'custom' === $args['menu-item-type'] ) {
579+
if ( 'custom' === $args['menu-item-type'] || 'placeholder' === $args['menu-item-type'] ) {
577580
$args['menu-item-object-id'] = $menu_item_db_id;
578-
$args['menu-item-object'] = 'custom';
581+
$args['menu-item-object'] = $args['menu-item-type'];
579582
}
580583

581584
$menu_item_db_id = (int) $menu_item_db_id;
@@ -950,6 +953,10 @@ function wp_setup_nav_menu_item( $menu_item ) {
950953

951954
$menu_item->title = ( '' === $menu_item->post_title ) ? $original_title : $menu_item->post_title;
952955

956+
} elseif ( 'placeholder' === $menu_item->type ) {
957+
$menu_item->type_label = __( 'Placeholder' );
958+
$menu_item->title = $menu_item->post_title;
959+
$menu_item->url = '';
953960
} else {
954961
$menu_item->type_label = __( 'Custom Link' );
955962
$menu_item->title = $menu_item->post_title;

src/wp-includes/rest-api/endpoints/class-wp-rest-menu-items-controller.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,16 @@ protected function prepare_item_for_database( $request ) {
468468
}
469469
}
470470

471+
// Placeholder items require a title and must not have a URL.
472+
if ( 'placeholder' === $prepared_nav_item['menu-item-type'] ) {
473+
if ( '' === $prepared_nav_item['menu-item-title'] ) {
474+
$error->add( 'rest_title_required', __( 'The title is required when using a placeholder menu item type.' ), array( 'status' => 400 ) );
475+
}
476+
if ( ! empty( $prepared_nav_item['menu-item-url'] ) ) {
477+
$error->add( 'rest_placeholder_url_invalid', __( 'A placeholder menu item type must not have a URL.' ), array( 'status' => 400 ) );
478+
}
479+
}
480+
471481
if ( $error->has_errors() ) {
472482
return $error;
473483
}
@@ -775,7 +785,7 @@ public function get_item_schema() {
775785
$schema['properties']['type'] = array(
776786
'description' => __( 'The family of objects originally represented, such as "post_type" or "taxonomy".' ),
777787
'type' => 'string',
778-
'enum' => array( 'taxonomy', 'post_type', 'post_type_archive', 'custom' ),
788+
'enum' => array( 'taxonomy', 'post_type', 'post_type_archive', 'custom', 'placeholder' ),
779789
'context' => array( 'view', 'edit', 'embed' ),
780790
'default' => 'custom',
781791
);

0 commit comments

Comments
 (0)