Skip to content

Commit

Permalink
Split control for URL and Text within Link UI (#33849)
Browse files Browse the repository at this point in the history
* Implement basic mechanics and UI

* Grab the selected/link text to use in Link UI

* Try to update the existing value with the new link and text

* Retain existing formats when applying new link format

* Document scenarios for clarity

* Only show text control once a link has been committed

* Strip HTML from Link Preview

* Strip HTML from link text control

* Allow submission of text changes via enter key

* Ensure user provided text has length before commiting as label value

* Migrate to using existing title prop of LinkControl

* Undo overzealous stripping of HTML. Leave this to escape on output.

Fixes issue whereby leading/trailing spaces would be stripped off the text value.

HTML is stripped out on render in the LinkControl now so we should be all ok.

* No change to text should preserve all formatting

* Avoid complications of preserving existing formats when amending link text via LinkControl

* Expand boundary seek algorithm in order to fix bugs

We'll circle back shortly to simplify this.

* Abstract walking loops to functions

* Reduce and simplify boundary algorithm

* Avoid reassignment of startIndex and prefer self documenting variable

* Improve edge case handling and function naming

* Add initial unit tests for Text control

* Add tests for more edge cases

* Correct text naming

* Don't modify value internally.

* Add tests to ensure LinkControl does not modify the value's title property

* Have Nav Link block strip HTML formatting before passing to LinkControl

* Avoid passing new function ref for handleSubmit

* Remove outdated reference to `text` prop of value

* Add guard against empty label

* Move utils to utils to expose for testing purposes

* Slice target text +1 beyond the end of the format boundary

* Add tests for getFormatValue and adjust implementation to get correct bounds

* Augment tests for getFormatBoundary

* Fix bug whereby rich text value passed to LinkControl when no active link format present

* Extract function for retrieving RichTextValue for a given selection

* Add basic e2e tests for link text

* Add test for preservation of whitespace

* Add e2e test covering modification of link text via Link UI

* Ensure focus is reliably placed within Link UI

* Fix test name typo

* Standardise test nomenclature on "submit" not "commit"

* Restore avoiding focus on mount to ensure collapsed selection can be made

* Improve e2e test code comments to explain creation of a collapsed selection

* Remove duplicate test

* Add doc block to getFormatBoundary function

* Conditionally show the visible URL input label

* Remove magic numbers from submit button positioning

Resolves #33849 (comment)

* Fix overflow bug introduced in earlier commit

* Remove stripHTML dependency and hand roll internal method

* Re-inline utility function to simplify.

* Reduce verbosity of walkToBoundary method

* Add doc block to walkToBoundary

* Refactor to reduce verbosity of getFormatBoundary

* Improve comments and self documentation of e2e test

Attempts to address #33849 (comment)

* Remove unwanted comment

* Rename helper util to disambiguate from text input focus

* Refine e2e tests

Avoid need to show the block toolbar and also avoid forcing stress testing the collapsed selection.

Addresses #33849 (comment)

* Rename an annotate unit tests

* Ensure boundary tests actually exercise code under test

* Refactor getFormatBoundary to handle more edge cases and put more of the code under test

* Fix to target icon based Edit button

* For currently active links use the full link text as the value of LinkControl ignoring any text selection

Resolves #33849 (review)

* Conform to style guide for coercion of Booleans

* Fix outdated test helper refs
  • Loading branch information
getdave authored Oct 26, 2021
1 parent f89c920 commit d00ec9a
Show file tree
Hide file tree
Showing 10 changed files with 1,115 additions and 73 deletions.
108 changes: 85 additions & 23 deletions packages/block-editor/src/components/link-control/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
* External dependencies
*/
import { noop } from 'lodash';
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { Button, Spinner, Notice } from '@wordpress/components';
import { Button, Spinner, Notice, TextControl } from '@wordpress/components';
import { keyboardReturn } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
import { useRef, useState, useEffect } from '@wordpress/element';
Expand Down Expand Up @@ -119,15 +120,21 @@ function LinkControl( {
noURLSuggestion = false,
createSuggestionButtonText,
hasRichPreviews = false,
hasTextControl = false,
} ) {
if ( withCreateSuggestion === undefined && createSuggestion ) {
withCreateSuggestion = true;
}

const isMounting = useRef( true );
const wrapperNode = useRef();
const textInputRef = useRef();

const [ internalInputValue, setInternalInputValue ] = useState(
( value && value.url ) || ''
value?.url || ''
);
const [ internalTextValue, setInternalTextValue ] = useState(
value?.title || ''
);
const currentInputValue = propInputValue || internalInputValue;
const [ isEditingLink, setIsEditingLink ] = useState(
Expand All @@ -149,22 +156,45 @@ function LinkControl( {
}, [ forceIsEditingLink ] );

useEffect( () => {
// We don't auto focus into the Link UI on mount
// because otherwise using the keyboard to select text
// *within* the link format is not possible.
if ( isMounting.current ) {
isMounting.current = false;
return;
}
// Unless we are mounting, we always want to focus either:
// - the URL input
// - the first focusable element in the Link UI.
// But in editing mode if there is a text input present then
// the URL input is at index 1. If not then it is at index 0.
const whichFocusTargetIndex = textInputRef?.current ? 1 : 0;

// When switching between editable and non editable LinkControl
// move focus to the first element to avoid focus loss.
// Scenario - when:
// - switching between editable and non editable LinkControl
// - clicking on a link
// ...then move focus to the *first* element to avoid focus loss
// and to ensure focus is *within* the Link UI.
const nextFocusTarget =
focus.focusable.find( wrapperNode.current )[ 0 ] ||
wrapperNode.current;
focus.focusable.find( wrapperNode.current )[
whichFocusTargetIndex
] || wrapperNode.current;

nextFocusTarget.focus();

isEndingEditWithFocus.current = false;
}, [ isEditingLink ] );

/**
* If the value's `text` property changes then sync this
* back up with state.
*/
useEffect( () => {
if ( value?.title && value.title !== internalTextValue ) {
setInternalTextValue( value.title );
}
}, [ value ] );

/**
* Cancels editing state and marks that focus may need to be restored after
* the next render, if focus was within the wrapper when editing finished.
Expand All @@ -182,22 +212,47 @@ function LinkControl( {
);

const handleSelectSuggestion = ( updatedValue ) => {
onChange( updatedValue );
onChange( {
...updatedValue,
title: internalTextValue || updatedValue?.title,
} );
stopEditing();
};

const handleSubmitButton = () => {
if ( currentInputValue !== value?.url ) {
onChange( { url: currentInputValue } );
const handleSubmit = () => {
if (
currentInputValue !== value?.url ||
internalTextValue !== value?.title
) {
onChange( {
url: currentInputValue,
title: internalTextValue,
} );
}
stopEditing();
};

const handleSubmitWithEnter = ( event ) => {
const { keyCode } = event;
if (
keyCode === ENTER &&
! currentInputIsEmpty // disallow submitting empty values.
) {
event.preventDefault();
handleSubmit();
}
};

const shownUnlinkControl =
onRemove && value && ! isEditingLink && ! isCreatingPage;

const showSettingsDrawer = !! settings?.length;

// Only show text control once a URL value has been committed
// and it isn't just empty whitespace.
// See https://github.com/WordPress/gutenberg/pull/33849/#issuecomment-932194927.
const showTextControl = value?.url?.trim()?.length && hasTextControl;

return (
<div
tabIndex={ -1 }
Expand All @@ -212,10 +267,26 @@ function LinkControl( {

{ ( isEditingLink || ! value ) && ! isCreatingPage && (
<>
<div className="block-editor-link-control__search-input-wrapper">
<div
className={ classnames( {
'block-editor-link-control__search-input-wrapper': true,
'has-text-control': showTextControl,
} ) }
>
{ showTextControl && (
<TextControl
ref={ textInputRef }
className="block-editor-link-control__field block-editor-link-control__text-content"
label="Text"
value={ internalTextValue }
onChange={ setInternalTextValue }
onKeyDown={ handleSubmitWithEnter }
/>
) }

<LinkControlSearchInput
currentLink={ value }
className="block-editor-link-control__search-input"
className="block-editor-link-control__field block-editor-link-control__search-input"
placeholder={ searchInputPlaceholder }
value={ currentInputValue }
withCreateSuggestion={ withCreateSuggestion }
Expand All @@ -230,20 +301,11 @@ function LinkControl( {
createSuggestionButtonText={
createSuggestionButtonText
}
useLabel={ showTextControl }
>
<div className="block-editor-link-control__search-actions">
<Button
onClick={ () => handleSubmitButton() }
onKeyDown={ ( event ) => {
const { keyCode } = event;
if (
keyCode === ENTER &&
! currentInputIsEmpty // disallow submitting empty values.
) {
event.preventDefault();
handleSubmitButton();
}
} }
onClick={ handleSubmit }
label={ __( 'Submit' ) }
icon={ keyboardReturn }
className="block-editor-link-control__search-submit"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@wordpress/components';
import { filterURLForDisplay, safeDecodeURI } from '@wordpress/url';
import { Icon, globe, info, linkOff, edit } from '@wordpress/icons';
import { __unstableStripHTML as stripHTML } from '@wordpress/dom';

/**
* Internal dependencies
Expand Down Expand Up @@ -41,6 +42,8 @@ export default function LinkPreview( {
( value && filterURLForDisplay( safeDecodeURI( value.url ), 16 ) ) ||
'';

const displayTitle = richData?.title || value?.title || displayURL;

const isEmptyURL = ! value.url.length;

let icon;
Expand Down Expand Up @@ -84,9 +87,7 @@ export default function LinkPreview( {
className="block-editor-link-control__search-item-title"
href={ value.url }
>
{ richData?.title ||
value?.title ||
displayURL }
{ stripHTML( displayTitle ) }
</ExternalLink>

{ value?.url && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* External dependencies
*/
import { noop, omit } from 'lodash';

import classnames from 'classnames';
/**
* WordPress dependencies
*/
Expand Down Expand Up @@ -45,6 +45,7 @@ const LinkControlSearchInput = forwardRef(
suggestionsQuery = {},
withURLSuggestion = true,
createSuggestionButtonText,
useLabel = false,
},
ref
) => {
Expand Down Expand Up @@ -117,10 +118,15 @@ const LinkControlSearchInput = forwardRef(
}
};

const inputClasses = classnames( className, {
'has-no-label': ! useLabel,
} );

return (
<div>
<div className="block-editor-link-control__search-input-container">
<URLInput
className={ className }
label={ useLabel ? 'URL' : undefined }
className={ inputClasses }
value={ value }
onChange={ onInputChange }
placeholder={ placeholder ?? __( 'Search or type url' ) }
Expand Down
43 changes: 32 additions & 11 deletions packages/block-editor/src/components/link-control/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,46 @@ $preview-image-height: 140px;
position: relative;
}

// LinkControl popover.
.block-editor-link-control .block-editor-link-control__search-input {
// Specificity override.
&.block-editor-link-control__search-input input[type="text"] {
// Provides positioning context for search actions
.block-editor-link-control__search-input-container {
position: relative;
}

// If the input doesn't have a visible label then
// we need to expand the input itself to occupy
// the full available horizontal space.
.block-editor-link-control__search-input.has-no-label .block-editor-url-input__input {
flex: 1;
}

.block-editor-link-control__field {
margin: $grid-unit-20; // allow margin collapse for vertical spacing.

// Element wrapping the label and input.
> .components-base-control__field {
display: flex;
align-items: center;
margin: 0;
}

.components-base-control__label {
margin-right: $grid-unit-20;
margin-bottom: 0;
}

input[type="text"],
// Specificity overide of URLInput defaults.
&.block-editor-url-input input[type="text"].block-editor-url-input__input {
@include input-control;
width: calc(100% - #{$grid-unit-20*2});
display: block;
padding: 11px $grid-unit-20;
padding-right: ( $button-size * $block-editor-link-control-number-of-actions ); // width of reset and submit buttons
margin: $grid-unit-20;
margin: 0;
position: relative;
border: 1px solid $gray-300;
border-radius: $radius-block-ui;
}

.components-base-control__field {
margin-bottom: 0;
}
}

.block-editor-link-control__search-error {
Expand All @@ -61,14 +83,13 @@ $preview-image-height: 140px;
* when suggestions are rendered.
*
* Compensate for:
* - Input margin ($grid-unit-20)
* - Border (1px)
* - Vertically, for the difference in height between the input (40px) and
* the icon buttons.
* - Horizontally, pad to the minimum of: default input padding, or the
* equivalent of the vertical padding.
*/
top: $grid-unit-20 + 1px + ( ( 40px - $button-size ) * 0.5 );
top: 1px + ( ( 40px - $button-size ) * 0.5 );
right: $grid-unit-20 + 1px + min($grid-unit-10, ( 40px - $button-size ) * 0.5);
}

Expand Down
Loading

0 comments on commit d00ec9a

Please sign in to comment.